From 1826f72d82a68ee0ae12cedca22060239e94dcf4 Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Thu, 30 Jul 2026 20:02:41 -0700 Subject: [PATCH 01/20] Rename files to "legacymac" --- .../backends/{backend_macosx.py => backend_legacymac.py} | 0 lib/matplotlib/backends/meson.build | 2 +- lib/matplotlib/backends/registry.py | 4 ++-- lib/matplotlib/pyplot.py | 2 +- src/{_macosx.m => _legacymac.m} | 0 src/meson.build | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename lib/matplotlib/backends/{backend_macosx.py => backend_legacymac.py} (100%) rename src/{_macosx.m => _legacymac.m} (100%) diff --git a/lib/matplotlib/backends/backend_macosx.py b/lib/matplotlib/backends/backend_legacymac.py similarity index 100% rename from lib/matplotlib/backends/backend_macosx.py rename to lib/matplotlib/backends/backend_legacymac.py diff --git a/lib/matplotlib/backends/meson.build b/lib/matplotlib/backends/meson.build index 1e3e47c0a915..f5cc158b8492 100644 --- a/lib/matplotlib/backends/meson.build +++ b/lib/matplotlib/backends/meson.build @@ -9,7 +9,7 @@ python_sources = [ 'backend_gtk4.py', 'backend_gtk4agg.py', 'backend_gtk4cairo.py', - 'backend_macosx.py', + 'backend_legacymac.py', 'backend_mixed.py', 'backend_nbagg.py', '_backend_pdf_ps.py', diff --git a/lib/matplotlib/backends/registry.py b/lib/matplotlib/backends/registry.py index 7f5a53f917a4..6e8ea2a04ceb 100644 --- a/lib/matplotlib/backends/registry.py +++ b/lib/matplotlib/backends/registry.py @@ -39,7 +39,7 @@ class BackendRegistry: "gtk3cairo": "gtk3", "gtk4agg": "gtk4", "gtk4cairo": "gtk4", - "macosx": "macosx", + "legacymac": "legacymac", "nbagg": "nbagg", "notebook": "nbagg", "qtagg": "qt", @@ -66,7 +66,7 @@ class BackendRegistry: "gtk3": "gtk3agg", "gtk4": "gtk4agg", "headless": "agg", - "macosx": "macosx", + "legacymac": "legacymac", "qt": "qtagg", "qt5": "qt5agg", "qt6": "qtagg", diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 8315056c81a2..9ead169a6b15 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -420,7 +420,7 @@ def switch_backend(newbackend: str) -> None: else: candidates = [] candidates += [ - "macosx", "qtagg", "gtk4agg", "gtk3agg", "tkagg", "wxagg"] + "legacymac", "qtagg", "gtk4agg", "gtk3agg", "tkagg", "wxagg"] # Don't try to fallback on the cairo-based backends as they each have # an additional dependency (pycairo) over the agg-based backend, and diff --git a/src/_macosx.m b/src/_legacymac.m similarity index 100% rename from src/_macosx.m rename to src/_legacymac.m diff --git a/src/meson.build b/src/meson.build index 9465c54e9f18..c9cea13bb82c 100644 --- a/src/meson.build +++ b/src/meson.build @@ -141,7 +141,7 @@ if get_option('macosx') and host_machine.system() == 'darwin' '_macosx', subdir: 'matplotlib/backends', sources: files( - '_macosx.m', + '_legacymac.m', ), dependencies: dependency('appleframeworks', modules: 'Cocoa'), override_options: ['werror=true'], From b0b62e4f4f3c93873336352bf439b80a709c1124 Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Thu, 30 Jul 2026 20:21:30 -0700 Subject: [PATCH 02/20] First pass at moving files from macosx-staging --- .github/labeler.yml | 4 +- lib/matplotlib/backends/backend_legacymac.py | 22 +- lib/matplotlib/backends/backend_macos.py | 199 +++ lib/matplotlib/backends/backend_macosx.py | 21 + lib/matplotlib/backends/meson.build | 2 + lib/matplotlib/backends/registry.py | 4 + lib/matplotlib/cbook.py | 5 +- src/macos/MPLAppDelegate.h | 4 + src/macos/MPLAppDelegate.m | 8 + src/macos/MPLFigureCanvas.h | 37 + src/macos/MPLFigureCanvas.m | 654 +++++++++ src/macos/MPLFigureManager.h | 7 + src/macos/MPLFigureManager.m | 19 + src/macos/MPLNavigationToolbar2.h | 16 + src/macos/MPLNavigationToolbar2.m | 37 + src/macos/MPLUtils.h | 6 + src/macos/MPLUtils.m | 41 + src/macos/_macos.m | 1286 ++++++++++++++++++ src/macos/meson.build | 18 + src/meson.build | 2 + 20 files changed, 2378 insertions(+), 14 deletions(-) create mode 100644 lib/matplotlib/backends/backend_macos.py create mode 100644 lib/matplotlib/backends/backend_macosx.py create mode 100644 src/macos/MPLAppDelegate.h create mode 100644 src/macos/MPLAppDelegate.m create mode 100644 src/macos/MPLFigureCanvas.h create mode 100644 src/macos/MPLFigureCanvas.m create mode 100644 src/macos/MPLFigureManager.h create mode 100644 src/macos/MPLFigureManager.m create mode 100644 src/macos/MPLNavigationToolbar2.h create mode 100644 src/macos/MPLNavigationToolbar2.m create mode 100644 src/macos/MPLUtils.h create mode 100644 src/macos/MPLUtils.m create mode 100755 src/macos/_macos.m create mode 100644 src/macos/meson.build diff --git a/.github/labeler.yml b/.github/labeler.yml index ee49868c8d7f..2cd3a4ce442c 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -47,8 +47,8 @@ "GUI: MacOSX": - changed-files: - any-glob-to-any-file: - - 'lib/matplotlib/backends/*_macosx.py*' - - 'src/_macosx.m' + - 'lib/matplotlib/backends/backend_legacymac.py' + - 'src/_legacymac.m' "GUI: nbagg": - changed-files: - any-glob-to-any-file: diff --git a/lib/matplotlib/backends/backend_legacymac.py b/lib/matplotlib/backends/backend_legacymac.py index b8d4a4a9cc01..262d024a02cd 100644 --- a/lib/matplotlib/backends/backend_legacymac.py +++ b/lib/matplotlib/backends/backend_legacymac.py @@ -10,7 +10,7 @@ CloseEvent, ResizeEvent, TimerBase, _allow_interrupt) -class TimerMac(_macosx.Timer, TimerBase): +class TimerLegacyMac(_macosx.Timer, TimerBase): """Subclass of `.TimerBase` using CFRunLoop timer events.""" # completely implemented at the C-level (in _macosx.Timer) @@ -21,7 +21,7 @@ def _allow_interrupt_macos(): lambda rsock: _macosx.wake_on_fd_write(rsock.fileno()), _macosx.stop) -class FigureCanvasMac(FigureCanvasAgg, _macosx.FigureCanvas, FigureCanvasBase): +class FigureCanvasLegacyMac(FigureCanvasAgg, _macosx.FigureCanvas, FigureCanvasBase): # docstring inherited # Ideally this class would be `class FCMacAgg(FCAgg, FCMac)` @@ -36,8 +36,8 @@ class FigureCanvasMac(FigureCanvasAgg, _macosx.FigureCanvas, FigureCanvasBase): # handled in C and events (MouseEvent, etc.) are triggered from there. required_interactive_framework = "macosx" - _timer_cls = TimerMac - manager_class = _api.classproperty(lambda cls: FigureManagerMac) + _timer_cls = TimerLegacyMac + manager_class = _api.classproperty(lambda cls: FigureManagerLegacyMac) def __init__(self, figure): super().__init__(figure=figure) @@ -113,7 +113,7 @@ def start_event_loop(self, timeout=0): self._start_event_loop(timeout=timeout) # Forward to ObjC implementation. -class NavigationToolbar2Mac(_macosx.NavigationToolbar2, NavigationToolbar2): +class NavigationToolbar2LegacyMac(_macosx.NavigationToolbar2, NavigationToolbar2): def __init__(self, canvas): data_path = cbook._get_data_path('images') @@ -145,8 +145,8 @@ def save_figure(self, *args): return filename -class FigureManagerMac(_macosx.FigureManager, FigureManagerBase): - _toolbar2_class = NavigationToolbar2Mac +class FigureManagerLegacyMac(_macosx.FigureManager, FigureManagerBase): + _toolbar2_class = NavigationToolbar2LegacyMac def __init__(self, canvas, num): self._shown = False @@ -193,7 +193,7 @@ def show(self): @_Backend.export -class _BackendMac(_Backend): - FigureCanvas = FigureCanvasMac - FigureManager = FigureManagerMac - mainloop = FigureManagerMac.start_main_loop +class _BackendLegacyMac(_Backend): + FigureCanvas = FigureCanvasLegacyMac + FigureManager = FigureManagerLegacyMac + mainloop = FigureManagerLegacyMac.start_main_loop diff --git a/lib/matplotlib/backends/backend_macos.py b/lib/matplotlib/backends/backend_macos.py new file mode 100644 index 000000000000..26d09ac75d16 --- /dev/null +++ b/lib/matplotlib/backends/backend_macos.py @@ -0,0 +1,199 @@ +import os + +import matplotlib as mpl +from matplotlib import _api, cbook +from matplotlib._pylab_helpers import Gcf +from . import _macos +from .backend_agg import FigureCanvasAgg +from matplotlib.backend_bases import ( + _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2, + CloseEvent, ResizeEvent, TimerBase, _allow_interrupt) + + +class TimerMac(_macos.Timer, TimerBase): + """Subclass of `.TimerBase` using CFRunLoop timer events.""" + # completely implemented at the C-level (in _macos.Timer) + + +def _allow_interrupt_macos(): + """A context manager that allows terminating a plot by sending a SIGINT.""" + return _allow_interrupt( + lambda rsock: _macos.wake_on_fd_write(rsock.fileno()), _macos.stop) + + +class FigureCanvasMac(FigureCanvasAgg, _macos.FigureCanvas, FigureCanvasBase): + # docstring inherited + + # 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. + + required_interactive_framework = "macosx" + _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() + + 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() + + 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() + + def blit(self, bbox=None): + # docstring inherited + super().blit(bbox) + self.update() + + def resize(self, width, height): + # Size from macOS is logical pixels, dpi is physical. + scale = self.figure.dpi / self.device_pixel_ratio + width /= scale + height /= scale + self.figure.set_size_inches(width, height, forward=False) + ResizeEvent("resize_event", self)._process() + self.draw_idle() + + 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 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)) + NavigationToolbar2.__init__(self, canvas) + + def draw_rubberband(self, event, x0, y0, x1, y1): + self.canvas.set_rubberband(int(x0), int(y0), int(x1), int(y1)) + + def remove_rubberband(self): + self.canvas.remove_rubberband() + + 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()) + 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) + self.canvas.figure.savefig(filename) + return filename + + +class FigureManagerMac(_macos.FigureManager, FigureManagerBase): + _toolbar2_class = NavigationToolbar2Mac + + 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"]) + if self.toolbar is not None: + self.toolbar.update() + if mpl.is_interactive(): + self.show() + self.canvas.draw_idle() + + def _handle_window_will_close(self): + CloseEvent("close_event", self.canvas)._process() + + def _handle_window_should_close(self): + Gcf.destroy(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() + super().destroy() + + @classmethod + def start_main_loop(cls): + # Set up a SIGINT handler to allow terminating a plot via CTRL-C. + with _allow_interrupt_macos(): + _macos.show() + + def show(self): + if self.canvas.figure.stale: + self.canvas.draw_idle() + if not self._shown: + self._show() + self._shown = True + if mpl.rcParams["figure.raise_window"]: + self._raise() + + +@_Backend.export +class _BackendMac(_Backend): + FigureCanvas = FigureCanvasMac + FigureManager = FigureManagerMac + mainloop = FigureManagerMac.start_main_loop diff --git a/lib/matplotlib/backends/backend_macosx.py b/lib/matplotlib/backends/backend_macosx.py new file mode 100644 index 000000000000..15f493be5375 --- /dev/null +++ b/lib/matplotlib/backends/backend_macosx.py @@ -0,0 +1,21 @@ +import sys +from . import backend_legacymac as _legacymac +from matplotlib.backend_bases import _Backend + +_RENAMES = { + 'FigureCanvasMac': 'FigureCanvasLegacyMac', + 'FigureManagerMac': 'FigureManagerLegacyMac', + 'NavigationToolbar2': 'NavigationToolbar2LegacyMac' +} + +_mod = sys.modules[__name__] +for _new, _old in _RENAMES.items(): + setattr(_mod, _new, getattr(_legacymac, _old)) +del _mod, _new, _old + + +@_Backend.export +class _BackendMac(_Backend): + FigureCanvas = _legacymac.FigureCanvasLegacyMac + FigureManager = _legacymac.FigureManagerLegacyMac + mainloop = _legacymac.FigureManagerLegacyMac.start_main_loop \ No newline at end of file diff --git a/lib/matplotlib/backends/meson.build b/lib/matplotlib/backends/meson.build index f5cc158b8492..0c875b4e8d4b 100644 --- a/lib/matplotlib/backends/meson.build +++ b/lib/matplotlib/backends/meson.build @@ -10,6 +10,8 @@ python_sources = [ 'backend_gtk4agg.py', 'backend_gtk4cairo.py', 'backend_legacymac.py', + 'backend_macosx.py', + 'backend_macos.py', 'backend_mixed.py', 'backend_nbagg.py', '_backend_pdf_ps.py', diff --git a/lib/matplotlib/backends/registry.py b/lib/matplotlib/backends/registry.py index 6e8ea2a04ceb..39ba19f8d3bd 100644 --- a/lib/matplotlib/backends/registry.py +++ b/lib/matplotlib/backends/registry.py @@ -40,6 +40,8 @@ class BackendRegistry: "gtk4agg": "gtk4", "gtk4cairo": "gtk4", "legacymac": "legacymac", + "macosx": "macosx", + "macos": "macos", "nbagg": "nbagg", "notebook": "nbagg", "qtagg": "qt", @@ -67,6 +69,8 @@ class BackendRegistry: "gtk4": "gtk4agg", "headless": "agg", "legacymac": "legacymac", + "macos": "macos", + "macosx": "macosx", "qt": "qtagg", "qt5": "qt5agg", "qt6": "qtagg", diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index 7aaf900d4e61..ca63f954265b 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -68,7 +68,7 @@ def _get_running_interactive_framework(): ------- Optional[str] One of the following values: "qt", "gtk3", "gtk4", "wx", "tk", - "macosx", "headless", ``None``. + "macos", "macosx", "headless", ``None``. """ # Use ``sys.modules.get(name)`` rather than ``name in sys.modules`` as # entries can also have been explicitly set to None. @@ -100,6 +100,9 @@ def _get_running_interactive_framework(): frame = frame.f_back # Preemptively break reference cycle between locals and the frame. del frame + macos = sys.modules.get("matplotlib.backends._macos") + if macos and macos.event_loop_is_running(): + return "macos" macosx = sys.modules.get("matplotlib.backends._macosx") if macosx and macosx.event_loop_is_running(): return "macosx" diff --git a/src/macos/MPLAppDelegate.h b/src/macos/MPLAppDelegate.h new file mode 100644 index 000000000000..d19281c18656 --- /dev/null +++ b/src/macos/MPLAppDelegate.h @@ -0,0 +1,4 @@ +#import + +@interface MPLAppDelegate : NSObject +@end diff --git a/src/macos/MPLAppDelegate.m b/src/macos/MPLAppDelegate.m new file mode 100644 index 000000000000..679d829d552e --- /dev/null +++ b/src/macos/MPLAppDelegate.m @@ -0,0 +1,8 @@ +#import "MPLAppDelegate.h" + + +@implementation MPLAppDelegate +- (BOOL)applicationSupportsSecureRestorableState:(NSApplication *)app { + return YES; +} +@end diff --git a/src/macos/MPLFigureCanvas.h b/src/macos/MPLFigureCanvas.h new file mode 100644 index 000000000000..7fa55c230769 --- /dev/null +++ b/src/macos/MPLFigureCanvas.h @@ -0,0 +1,37 @@ +#import +#import + +/* Keep track of the current mouse up/down state for open/closed cursor hand */ +extern bool mpl_leftMouseGrabbing; + +@interface MPLFigureCanvas : NSView +{ NSRect rubberband; + @public double device_scale; +} +- (void)drawRect:(NSRect)rect; +- (void)updateDevicePixelRatio:(double)scale; +- (void)windowDidChangeBackingProperties:(NSNotification*)notification; +- (void)windowDidResize:(NSNotification*)notification; +- (instancetype)initWithFrame:(NSRect)rect; +- (void)mouseEntered:(NSEvent*)event; +- (void)mouseExited:(NSEvent*)event; +- (void)mouseDown:(NSEvent*)event; +- (void)mouseUp:(NSEvent*)event; +- (void)mouseDragged:(NSEvent*)event; +- (void)mouseMoved:(NSEvent*)event; +- (void)rightMouseDown:(NSEvent*)event; +- (void)rightMouseUp:(NSEvent*)event; +- (void)rightMouseDragged:(NSEvent*)event; +- (void)otherMouseDown:(NSEvent*)event; +- (void)otherMouseUp:(NSEvent*)event; +- (void)otherMouseDragged:(NSEvent*)event; +- (void)setRubberband:(NSRect)rect; +- (void)removeRubberband; +- (NSString*)convertKeyEvent:(NSEvent*)event; +- (void)keyDown:(NSEvent*)event; +- (void)keyUp:(NSEvent*)event; +- (void)scrollWheel:(NSEvent *)event; +- (BOOL)acceptsFirstResponder; +- (void)flagsChanged:(NSEvent*)event; +@property (nonatomic, assign) PyObject *pyObject; +@end diff --git a/src/macos/MPLFigureCanvas.m b/src/macos/MPLFigureCanvas.m new file mode 100644 index 000000000000..4097099254bb --- /dev/null +++ b/src/macos/MPLFigureCanvas.m @@ -0,0 +1,654 @@ +#import "MPLFigureCanvas.h" +#import "MPLUtils.h" +#import "MPLFigureManager.h" + + +static void _buffer_release(void* info, const void* data, size_t size) { + PyGILState_STATE gstate = PyGILState_Ensure(); + PyBuffer_Release((Py_buffer *)info); + free(info); + PyGILState_Release(gstate); +} + + +/* Keep track of modifier key states for flagsChanged + to keep track of press vs release */ +static bool lastCommand = false; +static bool lastControl = false; +static bool lastShift = false; +static bool lastOption = false; +static bool lastCapsLock = false; +/* Keep track of whether this specific key modifier was pressed or not */ +static bool keyChangeCommand = false; +static bool keyChangeControl = false; +static bool keyChangeShift = false; +static bool keyChangeOption = false; +static bool keyChangeCapsLock = false; + +bool mpl_leftMouseGrabbing = false; + +static CGFloat _get_device_scale(CGContextRef cr) +{ + CGSize pixelSize = CGContextConvertSizeToDeviceSpace(cr, CGSizeMake(1, 1)); + return pixelSize.width; +} + +bool mpl_check_button(bool present, PyObject* set, char const* name) { + PyObject* module = NULL, * cls = NULL, * button = NULL; + bool failed = ( + present + && (!(module = PyImport_ImportModule("matplotlib.backend_bases")) + || !(cls = PyObject_GetAttrString(module, "MouseButton")) + || !(button = PyObject_GetAttrString(cls, name)) + || PySet_Add(set, button))); + Py_XDECREF(module); + Py_XDECREF(cls); + Py_XDECREF(button); + return failed; +} + +PyObject* mpl_buttons() +{ + PyGILState_STATE gstate = PyGILState_Ensure(); + PyObject* set = NULL; + NSUInteger buttons = [NSEvent pressedMouseButtons]; + + if (!(set = PySet_New(NULL)) + || mpl_check_button(buttons & (1 << 0), set, "LEFT") + || mpl_check_button(buttons & (1 << 1), set, "RIGHT") + || mpl_check_button(buttons & (1 << 2), set, "MIDDLE") + || mpl_check_button(buttons & (1 << 3), set, "BACK") + || mpl_check_button(buttons & (1 << 4), set, "FORWARD")) { + Py_CLEAR(set); // On failure, return NULL with an exception set. + } + PyGILState_Release(gstate); + return set; +} + +bool mpl_check_modifier(bool present, PyObject* list, char const* name) +{ + PyObject* py_name = NULL; + bool failed = ( + present + && (!(py_name = PyUnicode_FromString(name)) + || (PyList_Append(list, py_name)))); + Py_XDECREF(py_name); + return failed; +} + +PyObject* mpl_modifiers(NSEvent* event) +{ + PyGILState_STATE gstate = PyGILState_Ensure(); + PyObject* list = NULL; + NSUInteger modifiers = [event modifierFlags]; + if (!(list = PyList_New(0)) + || mpl_check_modifier(modifiers & NSEventModifierFlagControl, list, "ctrl") + || mpl_check_modifier(modifiers & NSEventModifierFlagOption, list, "alt") + || mpl_check_modifier(modifiers & NSEventModifierFlagShift, list, "shift") + || mpl_check_modifier(modifiers & NSEventModifierFlagCommand, list, "cmd")) { + Py_CLEAR(list); // On failure, return NULL with an exception set. + } + PyGILState_Release(gstate); + return list; +} + + +@implementation MPLFigureCanvas { + // Private ivars will live here +} + + +#pragma mark - Lifecycle + +- (instancetype) initWithFrame:(NSRect)rect +{ + if (self = [super initWithFrame: rect]) { + rubberband = NSZeroRect; + device_scale = 1; + } + return self; +} + + +#pragma mark - Superclass Overrides + +// This will become a -viewDidChangeBackingProperties override +- (void) updateDevicePixelRatio:(double)scale +{ + PyObject *change = NULL; + PyGILState_STATE gstate = PyGILState_Ensure(); + + device_scale = scale; + + if (!(change = PyObject_CallMethod(_pyObject, "_set_device_pixel_ratio", "d", device_scale))) { + PyErr_Print(); + goto exit; + } + + if (PyObject_IsTrue(change)) { + // Notify that there was a resize_event that took place + process_event( + "ResizeEvent", "{s:s, s:O}", + "name", "resize_event", "canvas", _pyObject); + gil_call_method(_pyObject, "draw_idle"); + [self setNeedsDisplay: YES]; + } + +exit: + Py_XDECREF(change); + + PyGILState_Release(gstate); +} + +-(void) drawRect:(NSRect)rect +{ + PyObject* renderer = NULL; + PyObject* renderer_buffer = NULL; + + PyGILState_STATE gstate = PyGILState_Ensure(); + + CGContextRef cr = [[NSGraphicsContext currentContext] CGContext]; + + if (!(renderer = PyObject_CallMethod(_pyObject, "get_renderer", "")) + || !(renderer_buffer = PyObject_CallMethod(renderer, "buffer_rgba", ""))) { + PyErr_Print(); + goto exit; + } + if (_copy_agg_buffer(cr, renderer_buffer)) { + printf("copy_agg_buffer failed\n"); + goto exit; + } + if (!NSIsEmptyRect(rubberband)) { + // We use bezier paths so we can stroke the outside with a dash + // pattern alternating white/black with two separate paths offset + // in phase. + NSBezierPath *white_path = [NSBezierPath bezierPathWithRect: rubberband]; + NSBezierPath *black_path = [NSBezierPath bezierPathWithRect: rubberband]; + CGFloat dash_pattern[2] = {3, 3}; + [white_path setLineDash: dash_pattern count: 2 phase: 0]; + [black_path setLineDash: dash_pattern count: 2 phase: 3]; + [[NSColor whiteColor] setStroke]; + [white_path stroke]; + [[NSColor blackColor] setStroke]; + [black_path stroke]; + } + + exit: + Py_XDECREF(renderer_buffer); + Py_XDECREF(renderer); + + PyGILState_Release(gstate); +} + +// This becomes a -setFrameSize: override +- (void) windowDidResize:(NSNotification*)notification +{ + int width, height; + Window* window = [notification object]; + NSSize size = [[window contentView] frame].size; + NSRect rect = [self frame]; + + size.height -= rect.origin.y; + width = size.width; + height = size.height; + + PyGILState_STATE gstate = PyGILState_Ensure(); + PyObject* result = PyObject_CallMethod( + _pyObject, "resize", "ii", width, height); + if (result) + Py_DECREF(result); + else + PyErr_Print(); + PyGILState_Release(gstate); + [self setNeedsDisplay: YES]; +} + +- (BOOL) acceptsFirstResponder +{ + return YES; +} + + +#pragma mark - NSWindowDelegate + +// This goes away and we will use a -viewDidChangeBackingProperties override +- (void) windowDidChangeBackingProperties:(NSNotification *)notification +{ + Window *window = [notification object]; + + [self updateDevicePixelRatio: [window backingScaleFactor]]; +} + +// This gets moved to MPLFigureManager, which will be a NSWindowController subclass +- (void) windowWillClose:(NSNotification *)notification +{ + // A view should not be the delegate of a window, this check + // will go away with next refactor + Window *window = (Window *)[self window]; + if ([window isKindOfClass:[Window class]]) { + gil_call_method([window pyObject], "_handle_window_will_close"); + } +} + +// This gets moved to MPLFigureManager, which will be a NSWindowController subclass +- (BOOL) windowShouldClose:(NSNotification *)notification +{ + // A view should not be the delegate of a window, this check + // will go away with next refactor + Window *window = (Window *)[self window]; + if ([window isKindOfClass:[Window class]]) { + gil_call_method([window pyObject], "_handle_window_should_close"); + } + return YES; +} + + +#pragma mark - Keyboard Events + +- (NSString *) convertKeyEvent:(NSEvent *)event +{ + NSMutableString* returnkey = [NSMutableString string]; + if (keyChangeControl) { + // When control is the key that was pressed, return the full word + [returnkey appendString:@"control+"]; + } else if (([event modifierFlags] & NSEventModifierFlagControl)) { + // If control is already pressed, return the shortened version + [returnkey appendString:@"ctrl+"]; + } + if (([event modifierFlags] & NSEventModifierFlagOption) || keyChangeOption) { + [returnkey appendString:@"alt+" ]; + } + if (([event modifierFlags] & NSEventModifierFlagCommand) || keyChangeCommand) { + [returnkey appendString:@"cmd+" ]; + } + // Don't print caps_lock unless it was the key that got pressed + if (keyChangeCapsLock) { + [returnkey appendString:@"caps_lock+" ]; + } + + // flagsChanged event can't handle charactersIgnoringModifiers + // because it was a modifier key that was pressed/released + if (event.type != NSEventTypeFlagsChanged) { + NSString* specialchar; + switch ([[event charactersIgnoringModifiers] characterAtIndex:0]) { + case NSLeftArrowFunctionKey: specialchar = @"left"; break; + case NSRightArrowFunctionKey: specialchar = @"right"; break; + case NSUpArrowFunctionKey: specialchar = @"up"; break; + case NSDownArrowFunctionKey: specialchar = @"down"; break; + case NSF1FunctionKey: specialchar = @"f1"; break; + case NSF2FunctionKey: specialchar = @"f2"; break; + case NSF3FunctionKey: specialchar = @"f3"; break; + case NSF4FunctionKey: specialchar = @"f4"; break; + case NSF5FunctionKey: specialchar = @"f5"; break; + case NSF6FunctionKey: specialchar = @"f6"; break; + case NSF7FunctionKey: specialchar = @"f7"; break; + case NSF8FunctionKey: specialchar = @"f8"; break; + case NSF9FunctionKey: specialchar = @"f9"; break; + case NSF10FunctionKey: specialchar = @"f10"; break; + case NSF11FunctionKey: specialchar = @"f11"; break; + case NSF12FunctionKey: specialchar = @"f12"; break; + case NSF13FunctionKey: specialchar = @"f13"; break; + case NSF14FunctionKey: specialchar = @"f14"; break; + case NSF15FunctionKey: specialchar = @"f15"; break; + case NSF16FunctionKey: specialchar = @"f16"; break; + case NSF17FunctionKey: specialchar = @"f17"; break; + case NSF18FunctionKey: specialchar = @"f18"; break; + case NSF19FunctionKey: specialchar = @"f19"; break; + case NSScrollLockFunctionKey: specialchar = @"scroll_lock"; break; + case NSBreakFunctionKey: specialchar = @"break"; break; + case NSInsertFunctionKey: specialchar = @"insert"; break; + case NSDeleteFunctionKey: specialchar = @"delete"; break; + case NSHomeFunctionKey: specialchar = @"home"; break; + case NSEndFunctionKey: specialchar = @"end"; break; + case NSPageDownFunctionKey: specialchar = @"pagedown"; break; + case NSPageUpFunctionKey: specialchar = @"pageup"; break; + case NSDeleteCharacter: specialchar = @"backspace"; break; + case NSEnterCharacter: specialchar = @"enter"; break; + case NSTabCharacter: specialchar = @"tab"; break; + case NSCarriageReturnCharacter: specialchar = @"enter"; break; + case NSBackTabCharacter: specialchar = @"backtab"; break; + case 27: specialchar = @"escape"; break; + default: specialchar = nil; + } + if (specialchar) { + if (([event modifierFlags] & NSEventModifierFlagShift) || keyChangeShift) { + [returnkey appendString:@"shift+"]; + } + [returnkey appendString:specialchar]; + } else { + // charactersIgnoringModifiers is nullable; guard defensively in case + // an unexpected event type reaches this path. + NSString* chars = [event charactersIgnoringModifiers]; + if (chars) { + [returnkey appendString:chars]; + } + } + } else { + if (([event modifierFlags] & NSEventModifierFlagShift) || keyChangeShift) { + [returnkey appendString:@"shift+"]; + } + // Since it was a modifier event trim the final character of the string + // because we added in "+" earlier + [returnkey setString: [returnkey substringToIndex:[returnkey length] - 1]]; + } + + return returnkey; +} + +- (void) keyDown:(NSEvent *)event +{ + const char* s = [[self convertKeyEvent: event] UTF8String]; + NSPoint location = [[self window] mouseLocationOutsideOfEventStream]; + location = [self convertPoint: location fromView: nil]; + int x = location.x * device_scale, + y = location.y * device_scale; + if (s) { + process_event( + "KeyEvent", "{s:s, s:O, s:s, s:i, s:i}", + "name", "key_press_event", "canvas", _pyObject, "key", s, "x", x, "y", y); + } else { + process_event( + "KeyEvent", "{s:s, s:O, s:O, s:i, s:i}", + "name", "key_press_event", "canvas", _pyObject, "key", Py_None, "x", x, "y", y); + } +} + +- (void) keyUp:(NSEvent *)event +{ + const char* s = [[self convertKeyEvent: event] UTF8String]; + NSPoint location = [[self window] mouseLocationOutsideOfEventStream]; + location = [self convertPoint: location fromView: nil]; + int x = location.x * device_scale, + y = location.y * device_scale; + if (s) { + process_event( + "KeyEvent", "{s:s, s:O, s:s, s:i, s:i}", + "name", "key_release_event", "canvas", _pyObject, "key", s, "x", x, "y", y); + } else { + process_event( + "KeyEvent", "{s:s, s:O, s:O, s:i, s:i}", + "name", "key_release_event", "canvas", _pyObject, "key", Py_None, "x", x, "y", y); + } +} + +// flagsChanged gets called whenever a modifier key is pressed OR released +// so we need to handle both cases here +- (void) flagsChanged:(NSEvent *)event +{ + bool isPress = false; // true if key is pressed, false if key was released + + // Each if clause tests the two cases for each of the keys we can handle + // 1. If the modifier flag "command key" is pressed and it was not previously + // 2. If the modifier flag "command key" is not pressed and it was previously + // !! converts the result of the bitwise & operator to a logical boolean, + // which allows us to then bitwise xor (^) the result with a boolean (lastCommand). + if (!!([event modifierFlags] & NSEventModifierFlagCommand) ^ lastCommand) { + // Command pressed/released + lastCommand = !lastCommand; + keyChangeCommand = true; + isPress = lastCommand; + } else if (!!([event modifierFlags] & NSEventModifierFlagControl) ^ lastControl) { + // Control pressed/released + lastControl = !lastControl; + keyChangeControl = true; + isPress = lastControl; + } else if (!!([event modifierFlags] & NSEventModifierFlagShift) ^ lastShift) { + // Shift pressed/released + lastShift = !lastShift; + keyChangeShift = true; + isPress = lastShift; + } else if (!!([event modifierFlags] & NSEventModifierFlagOption) ^ lastOption) { + // Option pressed/released + lastOption = !lastOption; + keyChangeOption = true; + isPress = lastOption; + } else if (!!([event modifierFlags] & NSEventModifierFlagCapsLock) ^ lastCapsLock) { + // Capslock pressed/released + lastCapsLock = !lastCapsLock; + keyChangeCapsLock = true; + isPress = lastCapsLock; + } else { + // flag we don't handle + return; + } + + if (isPress) { + [self keyDown:event]; + } else { + [self keyUp:event]; + } + + // Reset the state for the key changes after handling the event + keyChangeCommand = false; + keyChangeControl = false; + keyChangeShift = false; + keyChangeOption = false; + keyChangeCapsLock = false; +} + + +#pragma mark - Mouse Events + +- (void) mouseEntered:(NSEvent *)event +{ + int x, y; + NSPoint location = [event locationInWindow]; + location = [self convertPoint: location fromView: nil]; + x = location.x * device_scale; + y = location.y * device_scale; + process_event( + "LocationEvent", "{s:s, s:O, s:i, s:i, s:N}", + "name", "figure_enter_event", "canvas", _pyObject, "x", x, "y", y, + "modifiers", mpl_modifiers(event)); +} + +- (void) mouseExited:(NSEvent *)event +{ + int x, y; + NSPoint location = [event locationInWindow]; + location = [self convertPoint: location fromView: nil]; + x = location.x * device_scale; + y = location.y * device_scale; + process_event( + "LocationEvent", "{s:s, s:O, s:i, s:i, s:N}", + "name", "figure_leave_event", "canvas", _pyObject, "x", x, "y", y, + "modifiers", mpl_modifiers(event)); +} + + +- (void) mouseMoved:(NSEvent *)event +{ + int x, y; + NSPoint location = [event locationInWindow]; + location = [self convertPoint: location fromView: nil]; + x = location.x * device_scale; + y = location.y * device_scale; + process_event( + "MouseEvent", "{s:s, s:O, s:i, s:i, s:N, s:N}", + "name", "motion_notify_event", "canvas", _pyObject, "x", x, "y", y, + "buttons", mpl_buttons(), "modifiers", mpl_modifiers(event)); +} + +- (void) scrollWheel:(NSEvent *)event +{ + int step; + float d = [event deltaY]; + if (d > 0) { step = 1; } + else if (d < 0) { step = -1; } + else return; + NSPoint location = [event locationInWindow]; + NSPoint point = [self convertPoint: location fromView: nil]; + int x = (int)round(point.x * device_scale); + int y = (int)round(point.y * device_scale - 1); + process_event( + "MouseEvent", "{s:s, s:O, s:i, s:i, s:i, s:N}", + "name", "scroll_event", "canvas", _pyObject, + "x", x, "y", y, "step", step, "modifiers", mpl_modifiers(event)); +} + +- (void) mouseDown:(NSEvent *)event +{ + int x, y; + int button; + int dblclick = 0; + NSPoint location = [event locationInWindow]; + location = [self convertPoint: location fromView: nil]; + x = location.x * device_scale; + y = location.y * device_scale; + switch ([event type]) + { case NSEventTypeLeftMouseDown: + { unsigned int modifier = [event modifierFlags]; + if (modifier & NSEventModifierFlagControl) + /* emulate a right-button click */ + button = 3; + else if (modifier & NSEventModifierFlagOption) + /* emulate a middle-button click */ + button = 2; + else + { + button = 1; + if ([NSCursor currentCursor]==[NSCursor openHandCursor]) { + mpl_leftMouseGrabbing = true; + [[NSCursor closedHandCursor] set]; + } + } + break; + } + case NSEventTypeOtherMouseDown: button = 2; break; + case NSEventTypeRightMouseDown: button = 3; break; + default: return; /* Unknown mouse event */ + } + if ([event clickCount] == 2) { + dblclick = 1; + } + process_event( + "MouseEvent", "{s:s, s:O, s:i, s:i, s:i, s:i, s:N}", + "name", "button_press_event", "canvas", _pyObject, "x", x, "y", y, + "button", button, "dblclick", dblclick, "modifiers", mpl_modifiers(event)); +} + +- (void) mouseUp:(NSEvent *)event +{ + int button; + int x, y; + NSPoint location = [event locationInWindow]; + location = [self convertPoint: location fromView: nil]; + x = location.x * device_scale; + y = location.y * device_scale; + switch ([event type]) + { case NSEventTypeLeftMouseUp: + mpl_leftMouseGrabbing = false; + button = 1; + if ([NSCursor currentCursor]==[NSCursor closedHandCursor]) + [[NSCursor openHandCursor] set]; + break; + case NSEventTypeOtherMouseUp: button = 2; break; + case NSEventTypeRightMouseUp: button = 3; break; + default: return; /* Unknown mouse event */ + } + process_event( + "MouseEvent", "{s:s, s:O, s:i, s:i, s:i, s:N}", + "name", "button_release_event", "canvas", _pyObject, "x", x, "y", y, + "button", button, "modifiers", mpl_modifiers(event)); +} + +// Funnel other down/up events to -mouseDown: or -mouseUp: +- (void) rightMouseDown:(NSEvent *)event { [self mouseDown:event]; } +- (void) otherMouseDown:(NSEvent *)event { [self mouseDown:event]; } +- (void) rightMouseUp: (NSEvent *)event { [self mouseUp:event]; } +- (void) otherMouseUp: (NSEvent *)event { [self mouseUp:event]; } + +// Funnel dragged events to -mouseMoved: +- (void) mouseDragged: (NSEvent *)event { [self mouseMoved:event]; } +- (void) rightMouseDragged:(NSEvent *)event { [self mouseMoved:event]; } +- (void) otherMouseDragged:(NSEvent *)event { [self mouseMoved:event]; } + + +#pragma mark - Public Methods + +// This will become -updateLayerWithBuffer: +static int _copy_agg_buffer(CGContextRef cr, PyObject *renderer) +{ + Py_buffer *buffer = malloc(sizeof(Py_buffer)); + + if (PyObject_GetBuffer(renderer, buffer, PyBUF_CONTIG_RO) == -1) { + PyErr_Print(); + return 1; + } + + if (buffer->ndim != 3 || buffer->shape[2] != 4) { + _buffer_release(buffer, NULL, 0); + return 1; + } + + const Py_ssize_t nrows = buffer->shape[0]; + const Py_ssize_t ncols = buffer->shape[1]; + const size_t bytesPerComponent = 1; + const size_t bitsPerComponent = 8 * bytesPerComponent; + const size_t nComponents = 4; /* red, green, blue, alpha */ + const size_t bitsPerPixel = bitsPerComponent * nComponents; + const size_t bytesPerRow = nComponents * bytesPerComponent * ncols; + + CGColorSpaceRef colorspace = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); + if (!colorspace) { + _buffer_release(buffer, NULL, 0); + return 1; + } + + CGDataProviderRef provider = CGDataProviderCreateWithData(buffer, + buffer->buf, + buffer->len, + _buffer_release); + if (!provider) { + _buffer_release(buffer, NULL, 0); + CGColorSpaceRelease(colorspace); + return 1; + } + + CGBitmapInfo bitmapInfo = kCGBitmapByteOrderDefault | kCGImageAlphaLast; + CGImageRef bitmap = CGImageCreate(ncols, + nrows, + bitsPerComponent, + bitsPerPixel, + bytesPerRow, + colorspace, + bitmapInfo, + provider, + NULL, + false, + kCGRenderingIntentDefault); + CGColorSpaceRelease(colorspace); + CGDataProviderRelease(provider); + + if (!bitmap) { + return 1; + } + + CGFloat deviceScale = _get_device_scale(cr); + CGContextSaveGState(cr); + CGContextDrawImage(cr, CGRectMake(0, 0, ncols/deviceScale, nrows/deviceScale), bitmap); + CGImageRelease(bitmap); + CGContextRestoreGState(cr); + + return 0; +} + +// Becomes -updateRubberbandWithDeviceX0:y0:x1:y1: +- (void) setRubberband:(NSRect)rect +{ + // The space we want to redraw is a union of the previous rubberband + // with the new rubberband and then expanded (negative inset) by one + // in each direction to account for the stroke linewidth. + [self setNeedsDisplayInRect: NSInsetRect(NSUnionRect(rect, rubberband), -1, -1)]; + rubberband = rect; +} + +- (void) removeRubberband +{ + if (NSIsEmptyRect(rubberband)) { return; } + [self setNeedsDisplayInRect: rubberband]; + rubberband = NSZeroRect; +} + + +@end diff --git a/src/macos/MPLFigureManager.h b/src/macos/MPLFigureManager.h new file mode 100644 index 000000000000..47d74b8bba59 --- /dev/null +++ b/src/macos/MPLFigureManager.h @@ -0,0 +1,7 @@ +#import +#import + +@interface Window : NSWindow +- (NSRect)constrainFrameRect:(NSRect)rect toScreen:(NSScreen*)screen; +@property (nonatomic, assign) PyObject *pyObject; +@end diff --git a/src/macos/MPLFigureManager.m b/src/macos/MPLFigureManager.m new file mode 100644 index 000000000000..04fbd93f27ae --- /dev/null +++ b/src/macos/MPLFigureManager.m @@ -0,0 +1,19 @@ +#import "MPLFigureManager.h" +#import "MPLUtils.h" + + +@implementation Window + +- (NSRect) constrainFrameRect:(NSRect)rect toScreen:(NSScreen *)screen +{ + // Allow the window height to be larger than the screen height + CGRect suggestedRect = [super constrainFrameRect:rect toScreen:screen]; + + const CGFloat difference = rect.size.height - suggestedRect.size.height; + suggestedRect.origin.y -= difference; + suggestedRect.size.height += difference; + + return suggestedRect; +} + +@end diff --git a/src/macos/MPLNavigationToolbar2.h b/src/macos/MPLNavigationToolbar2.h new file mode 100644 index 000000000000..44d762530314 --- /dev/null +++ b/src/macos/MPLNavigationToolbar2.h @@ -0,0 +1,16 @@ +#import +#import + +@interface MPLNavigationToolbar2 : NSObject +- (void)installCallbacks:(SEL[7])actions forButtons:(__strong NSButton*[7])buttons; +- (void)home:(id)sender; +- (void)back:(id)sender; +- (void)forward:(id)sender; +- (void)pan:(id)sender; +- (void)zoom:(id)sender; +- (void)configure_subplots:(id)sender; +- (void)save_figure:(id)sender; +@property (nonatomic, assign) PyObject *pyObject; +@property (nonatomic, readonly) NSButton *panButton; +@property (nonatomic, readonly) NSButton *zoomButton; +@end diff --git a/src/macos/MPLNavigationToolbar2.m b/src/macos/MPLNavigationToolbar2.m new file mode 100644 index 000000000000..8a3e89878916 --- /dev/null +++ b/src/macos/MPLNavigationToolbar2.m @@ -0,0 +1,37 @@ +#import "MPLNavigationToolbar2.h" +#import "MPLUtils.h" + + +@implementation MPLNavigationToolbar2 + +- (void)installCallbacks:(SEL[7])actions forButtons:(__strong NSButton*[7])buttons +{ + for (int i = 0; i < 7; i++) { + SEL action = actions[i]; + NSButton* button = buttons[i]; + [button setTarget: self]; + [button setAction: action]; + if (action == @selector(pan:)) { _panButton = button; } + if (action == @selector(zoom:)) { _zoomButton = button; } + } +} + +-(void)home:(id)sender { gil_call_method(_pyObject, "home"); } +-(void)back:(id)sender { gil_call_method(_pyObject, "back"); } +-(void)forward:(id)sender { gil_call_method(_pyObject, "forward"); } + +-(void)pan:(id)sender +{ + if ([sender state]) { [_zoomButton setState:NO]; } + gil_call_method(_pyObject, "pan"); +} + +-(void)zoom:(id)sender +{ + if ([sender state]) { [_panButton setState:NO]; } + gil_call_method(_pyObject, "zoom"); +} + +-(void)configure_subplots:(id)sender { gil_call_method(_pyObject, "configure_subplots"); } +-(void)save_figure:(id)sender { gil_call_method(_pyObject, "save_figure"); } +@end diff --git a/src/macos/MPLUtils.h b/src/macos/MPLUtils.h new file mode 100644 index 000000000000..3b42fccceec6 --- /dev/null +++ b/src/macos/MPLUtils.h @@ -0,0 +1,6 @@ +#import +#import + +void gil_call_method(PyObject* obj, const char* name); + +void process_event(char const* cls_name, char const* fmt, ...); diff --git a/src/macos/MPLUtils.m b/src/macos/MPLUtils.m new file mode 100644 index 000000000000..3b172df454bf --- /dev/null +++ b/src/macos/MPLUtils.m @@ -0,0 +1,41 @@ +#import "MPLUtils.h" + +// Acquire the GIL, call a method with no args, discarding the result and +// printing any exception. +void gil_call_method(PyObject* obj, const char* name) +{ + PyGILState_STATE gstate = PyGILState_Ensure(); + PyObject* result = PyObject_CallMethod(obj, name, NULL); + if (result) { + Py_DECREF(result); + } else { + PyErr_Print(); + } + PyGILState_Release(gstate); +} + +void process_event(char const* cls_name, char const* fmt, ...) +{ + PyGILState_STATE gstate = PyGILState_Ensure(); + PyObject* module = NULL, * cls = NULL, + * args = NULL, * kwargs = NULL, + * event = NULL, * result = NULL; + va_list argp; + va_start(argp, fmt); + if (!(module = PyImport_ImportModule("matplotlib.backend_bases")) + || !(cls = PyObject_GetAttrString(module, cls_name)) + || !(args = PyTuple_New(0)) + || !(kwargs = Py_VaBuildValue(fmt, argp)) + || !(event = PyObject_Call(cls, args, kwargs)) + || !(result = PyObject_CallMethod(event, "_process", ""))) { + PyErr_Print(); + } + va_end(argp); + Py_XDECREF(module); + Py_XDECREF(cls); + Py_XDECREF(args); + Py_XDECREF(kwargs); + Py_XDECREF(event); + Py_XDECREF(result); + PyGILState_Release(gstate); +} diff --git a/src/macos/_macos.m b/src/macos/_macos.m new file mode 100755 index 000000000000..6df636eed0db --- /dev/null +++ b/src/macos/_macos.m @@ -0,0 +1,1286 @@ +#define PY_SSIZE_T_CLEAN +#import +#import +#import +#import "MPLUtils.h" +#import "MPLAppDelegate.h" +#import "MPLFigureCanvas.h" +#import "MPLFigureManager.h" +#import "MPLNavigationToolbar2.h" + +#if !__has_feature(objc_arc_fields) +#error "The macOS backend requires ARC C struct fields support (objc_arc_fields)." +#endif + +/* Various NSApplicationDefined event subtypes */ +#define STOP_EVENT_LOOP 2 + + +/* When calling into Objective-C from Python, wrap the calls with + BEGIN_OBJC_ENTRY and END_OBJC_ENTRY. This will set up an autorelease + pool as well as catch any Obj-C exceptions thrown. These macros + should be used for any call exposed to Python via the external module + interface. + + To avoid undefined behavior, each END_OBJC_ENTRY should be followed + by a return statement which handles the rare case when an Objective-C + exception was thrown. + + As a convenience, the RETURN_NULL_OR_NONE macro can be used for functions + that return a PyObject */ +#define BEGIN_OBJC_ENTRY \ + @autoreleasepool { @try { + +#define END_OBJC_ENTRY \ + } @catch (NSException *e) { errSetException(e); } } + +#define RETURN_NULL_OR_NONE \ + if (PyErr_Occurred()) { \ + return NULL; \ + } else { \ + Py_RETURN_NONE; \ + } + + +/* Variable for our delegate since it needs a +1 reference count. */ +static id appDelegate = nil; + +/* Variables to keep track of state and window count for show() */ +static BOOL IsRunningFromShow = NO; +static NSHashTable *FigureWindowHashTable = nil; + +// Global variable to store the original SIGINT handler +static PyOS_sighandler_t originalSigintAction = NULL; + +// Convert an Objective-C exception into a Python RuntimeError +static void errSetException(NSException *exception) { + PyErr_SetString(PyExc_RuntimeError, [[exception reason] UTF8String]); +} + +// Stop the current app's run loop, sending an event to ensure it actually stops +static void stopWithEvent(void) { + [NSApp stop: nil]; + // Post an event to trigger the actual stopping. + // +[NSEvent otherEventWithType:...] is declared nullable but will not return + // nil for these constant, valid arguments; guard defensively anyway. + NSEvent* event = [NSEvent otherEventWithType: NSEventTypeApplicationDefined + location: NSZeroPoint + modifierFlags: 0 + timestamp: 0 + windowNumber: 0 + context: nil + subtype: 0 + data1: 0 + data2: 0]; + if (event) { + [NSApp postEvent: event atStart: YES]; + } +} + +// Signal handler for SIGINT, only argument matching for stopWithEvent +static void handleSigint(int signal) { + stopWithEvent(); +} + +// Helper function to flush all events. +// This is needed in some instances to ensure e.g. that windows are properly closed. +// It is used in the input hook as well as wrapped in a version callable from Python. +static void flushEvents(void) { + while (true) { + @autoreleasepool { + NSEvent* event = [NSApp nextEventMatchingMask: NSEventMaskAny + untilDate: [NSDate distantPast] + inMode: NSDefaultRunLoopMode + dequeue: YES]; + if (!event) { + break; + } + [NSApp sendEvent:event]; + } + } +} + +static int wait_for_stdin(void) { + BEGIN_OBJC_ENTRY + + // Short circuit if no windows are active + // Rely on Python's input handling to manage CPU usage + // This queries the NSApp, rather than using our FigureWindowHashTable because that is modified when events still + // need to be processed to properly close the windows. + @autoreleasepool { + if (![[NSApp windows] count]) { + flushEvents(); + return 1; + } + } + + // Set up a SIGINT handler to interrupt the event loop if ctrl+c comes in too + originalSigintAction = PyOS_setsig(SIGINT, handleSigint); + + // Create an NSFileHandle for standard input + NSFileHandle *stdinHandle = [NSFileHandle fileHandleWithStandardInput]; + + + // Register for data available notifications on standard input + id notificationID = [[NSNotificationCenter defaultCenter] addObserverForName: NSFileHandleDataAvailableNotification + object: stdinHandle + queue: [NSOperationQueue mainQueue] // Use the main queue + usingBlock: ^(NSNotification *notification) {stopWithEvent();} + ]; + + // Wait in the background for anything that happens to stdin + [stdinHandle waitForDataInBackgroundAndNotify]; + + // Run the application's event loop, which will be interrupted on stdin or SIGINT + [NSApp run]; + + // Remove the input handler as an observer + [[NSNotificationCenter defaultCenter] removeObserver: notificationID]; + + + // Restore the original SIGINT handler upon exiting the function + PyOS_setsig(SIGINT, originalSigintAction); + + return 1; + + END_OBJC_ENTRY + return 0; +} + + +/* ---------------------------- Python classes ---------------------------- */ + + +static bool backend_inited = false; + +static void lazy_init(void) { + if (backend_inited) { return; } + backend_inited = true; + + NSApp = [NSApplication sharedApplication]; + [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; + appDelegate = [[MPLAppDelegate alloc] init]; + [NSApp setDelegate:appDelegate]; + + // Run our own event loop while waiting for stdin on the Python side + // this is needed to keep the application responsive while waiting for input + PyOS_InputHook = wait_for_stdin; +} + +static PyObject * +event_loop_is_running(PyObject *self) +{ + BEGIN_OBJC_ENTRY + + if (backend_inited) { + Py_RETURN_TRUE; + } else { + Py_RETURN_FALSE; + } + + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +wake_on_fd_write(PyObject *unused, PyObject *args) +{ + BEGIN_OBJC_ENTRY + int fd; + if (!PyArg_ParseTuple(args, "i", &fd)) { return NULL; } + NSFileHandle* fh = [[NSFileHandle alloc] initWithFileDescriptor: fd]; + __block id notificationID = [[NSNotificationCenter defaultCenter] + addObserverForName: NSFileHandleDataAvailableNotification + object: fh + queue: nil + usingBlock: ^(NSNotification* note) { + NSFileHandle *strongFileHandle __attribute__((unused)) = fh; + PyGILState_STATE gstate = PyGILState_Ensure(); + PyErr_CheckSignals(); + PyGILState_Release(gstate); + [[NSNotificationCenter defaultCenter] removeObserver:notificationID]; + }]; + [fh waitForDataInBackgroundAndNotify]; + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +stop(PyObject *self, PyObject *unused) +{ + BEGIN_OBJC_ENTRY + stopWithEvent(); + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + + +#pragma mark - FigureCanvas Type + +typedef struct { + PyObject_HEAD + __strong MPLFigureCanvas *object; +} FigureCanvas; + +static PyTypeObject FigureCanvasType; + +static PyObject * +FigureCanvas_new(PyTypeObject *type, PyObject *args, PyObject *kwds) +{ + BEGIN_OBJC_ENTRY + + lazy_init(); + return (PyObject *)((FigureCanvas *)type->tp_alloc(type, 0)); + + END_OBJC_ENTRY + return NULL; +} + +static int +FigureCanvas_init(FigureCanvas *self, PyObject *args, PyObject *kwds) +{ + BEGIN_OBJC_ENTRY + MPLFigureCanvas *wrappedObject; + NSTrackingArea *trackingArea; + PyObject *builtins = NULL, + *super_obj = NULL, + *super_init = NULL, + *init_res = NULL, + *wh = NULL; + // super(FigureCanvasMac, self).__init__(*args, **kwargs) + if (!(builtins = PyImport_AddModule("builtins")) // borrowed. + || !(super_obj = PyObject_CallMethod(builtins, "super", "OO", &FigureCanvasType, self)) + || !(super_init = PyObject_GetAttrString(super_obj, "__init__")) + || !(init_res = PyObject_Call(super_init, args, kwds))) { + goto exit; + } + int width, height; + if (!(wh = PyObject_CallMethod((PyObject *)self, "get_width_height", "")) + || !PyArg_ParseTuple(wh, "ii", &width, &height)) { + goto exit; + } + NSRect rect = NSMakeRect(0.0, 0.0, width, height); + wrappedObject = [[MPLFigureCanvas alloc] initWithFrame: rect]; + wrappedObject.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + int opts = (NSTrackingMouseEnteredAndExited | NSTrackingMouseMoved | + NSTrackingActiveInKeyWindow | NSTrackingInVisibleRect); + trackingArea = [[NSTrackingArea alloc] initWithRect: rect + options: opts + owner: wrappedObject + userInfo: nil]; + [wrappedObject addTrackingArea:trackingArea]; + self->object = wrappedObject; + [self->object setPyObject:(PyObject *)self]; + +exit: + Py_XDECREF(super_obj); + Py_XDECREF(super_init); + Py_XDECREF(init_res); + Py_XDECREF(wh); + + END_OBJC_ENTRY + return PyErr_Occurred() ? -1 : 0; +} + +static void +FigureCanvas_dealloc(FigureCanvas *self) +{ + BEGIN_OBJC_ENTRY + [self->object setPyObject:NULL]; + self->object = nil; + END_OBJC_ENTRY + Py_TYPE(self)->tp_free((PyObject *)self); +} + +static PyObject * +FigureCanvas_repr(FigureCanvas *self) +{ + return PyUnicode_FromFormat("FigureCanvas<%p> wrapping MPLFigureCanvas<%p>", + (void *)self, (__bridge void *)self->object); +} + +static PyObject * +FigureCanvas_update(FigureCanvas *self) +{ + BEGIN_OBJC_ENTRY + [self->object setNeedsDisplay: YES]; + END_OBJC_ENTRY + RETURN_NULL_OR_NONE; +} + +static PyObject * +FigureCanvas_flush_events(FigureCanvas *self) +{ + BEGIN_OBJC_ENTRY + // We run the app, matching any events that are waiting in the queue + // to process, breaking out of the loop when no events remain and + // displaying the canvas if needed. + Py_BEGIN_ALLOW_THREADS + + flushEvents(); + + Py_END_ALLOW_THREADS + + [self->object displayIfNeeded]; + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * __attribute__((unused)) +FigureCanvas_request_idle_draw(FigureCanvas *self) +{ + BEGIN_OBJC_ENTRY + // Will be implemented + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +FigureCanvas_set_cursor(FigureCanvas *self, PyObject *args) +{ + BEGIN_OBJC_ENTRY + int i; + if (!PyArg_ParseTuple(args, "i", &i)) { return NULL; } + switch (i) { + case 1: [[NSCursor arrowCursor] set]; break; + case 2: [[NSCursor pointingHandCursor] set]; break; + case 3: [[NSCursor crosshairCursor] set]; break; + case 4: + if (mpl_leftMouseGrabbing) { + [[NSCursor closedHandCursor] set]; + } else { + [[NSCursor openHandCursor] set]; + } + break; + /* macOS handles busy state itself so no need to set a cursor here */ + case 5: break; + case 6: [[NSCursor resizeLeftRightCursor] set]; break; + case 7: [[NSCursor resizeUpDownCursor] set]; break; + default: return NULL; + } + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +FigureCanvas_set_rubberband(FigureCanvas *self, PyObject *args) +{ + BEGIN_OBJC_ENTRY + MPLFigureCanvas *figureCanvas = self->object; + if (!figureCanvas) { + PyErr_SetString(PyExc_RuntimeError, "MPLFigureCanvas* is NULL"); + return NULL; + } + int x0, y0, x1, y1; + if (!PyArg_ParseTuple(args, "iiii", &x0, &y0, &x1, &y1)) { + return NULL; + } + x0 /= figureCanvas->device_scale; + x1 /= figureCanvas->device_scale; + y0 /= figureCanvas->device_scale; + y1 /= figureCanvas->device_scale; + NSRect rubberband = NSMakeRect(x0 < x1 ? x0 : x1, y0 < y1 ? y0 : y1, + abs(x1 - x0), abs(y1 - y0)); + [figureCanvas setRubberband: rubberband]; + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +FigureCanvas_remove_rubberband(FigureCanvas *self) +{ + BEGIN_OBJC_ENTRY + [self->object removeRubberband]; + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +FigureCanvas__start_event_loop(FigureCanvas *self, PyObject *args, PyObject *keywords) +{ + BEGIN_OBJC_ENTRY + float timeout = 0.0; + + static char *kwlist[] = {"timeout", NULL}; + if (!PyArg_ParseTupleAndKeywords(args, keywords, "f", kwlist, &timeout)) { + return NULL; + } + + Py_BEGIN_ALLOW_THREADS + + NSDate *date = + (timeout > 0.0) ? [NSDate dateWithTimeIntervalSinceNow: timeout] + : [NSDate distantFuture]; + while (true) { + @autoreleasepool { + NSEvent *event = [NSApp nextEventMatchingMask: NSEventMaskAny + untilDate: date + inMode: NSDefaultRunLoopMode + dequeue: YES]; + if (!event || [event type]==NSEventTypeApplicationDefined) { break; } + [NSApp sendEvent: event]; + } + } + + Py_END_ALLOW_THREADS + + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +FigureCanvas_stop_event_loop(FigureCanvas *self) +{ + BEGIN_OBJC_ENTRY + // +[NSEvent otherEventWithType:...] is declared nullable but will not return + // nil for these constant, valid arguments; guard defensively anyway. + NSEvent* event = [NSEvent otherEventWithType: NSEventTypeApplicationDefined + location: NSZeroPoint + modifierFlags: 0 + timestamp: 0.0 + windowNumber: 0 + context: nil + subtype: STOP_EVENT_LOOP + data1: 0 + data2: 0]; + if (event) { + [NSApp postEvent: event atStart: true]; + } + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyTypeObject FigureCanvasType = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "matplotlib.backends._macosx.FigureCanvas", + .tp_doc = PyDoc_STR("A FigureCanvas object wraps a Cocoa NSView object."), + .tp_basicsize = sizeof(FigureCanvas), + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + + .tp_new = (newfunc)FigureCanvas_new, + .tp_init = (initproc)FigureCanvas_init, + .tp_dealloc = (destructor)FigureCanvas_dealloc, + .tp_repr = (reprfunc)FigureCanvas_repr, + + .tp_methods = (PyMethodDef[]){ + {"update", + (PyCFunction)FigureCanvas_update, + METH_NOARGS, + NULL}, // docstring inherited + {"flush_events", + (PyCFunction)FigureCanvas_flush_events, + METH_NOARGS, + NULL}, // docstring inherited + {"set_cursor", + (PyCFunction)FigureCanvas_set_cursor, + METH_VARARGS, + PyDoc_STR("Set the active cursor.")}, + {"set_rubberband", + (PyCFunction)FigureCanvas_set_rubberband, + METH_VARARGS, + PyDoc_STR("Specify a new rubberband rectangle and invalidate it.")}, + {"remove_rubberband", + (PyCFunction)FigureCanvas_remove_rubberband, + METH_NOARGS, + PyDoc_STR("Remove the current rubberband rectangle.")}, + {"_start_event_loop", + (PyCFunction)FigureCanvas__start_event_loop, + METH_KEYWORDS | METH_VARARGS, + NULL}, // docstring inherited + {"stop_event_loop", + (PyCFunction)FigureCanvas_stop_event_loop, + METH_NOARGS, + NULL}, // docstring inherited + {} // sentinel + }, +}; + + +#pragma mark - FigureManager Type + +static PyTypeObject FigureManagerType; // forward declaration, needed in destroy() + +typedef struct { + PyObject_HEAD + __strong Window *object; +} FigureManager; + +static PyObject * +FigureManager_new(PyTypeObject *type, PyObject *args, PyObject *kwds) +{ + BEGIN_OBJC_ENTRY + if (![NSThread isMainThread]) { + PyErr_SetString( + PyExc_RuntimeError, + "Cannot create a GUI FigureManager outside the main thread " + "using the MacOS backend. Use a non-interactive " + "backend like 'agg' to make plots on worker threads." + ); + return NULL; + } + + lazy_init(); + return (PyObject *)((FigureManager *)type->tp_alloc(type, 0)); + + END_OBJC_ENTRY + return NULL; +} + +static int +FigureManager_init(FigureManager *self, PyObject *args, PyObject *kwds) +{ + BEGIN_OBJC_ENTRY + PyObject *canvas; + if (!PyArg_ParseTuple(args, "O", &canvas)) { + return -1; + } + + MPLFigureCanvas *figureCanvas = ((FigureCanvas*)canvas)->object; + if (!figureCanvas) { /* Something really weird going on */ + PyErr_SetString(PyExc_RuntimeError, "MPLFigureCanvas* is NULL"); + return -1; + } + + PyObject *size = PyObject_CallMethod(canvas, "get_width_height", ""); + int width, height; + if (!size || !PyArg_ParseTuple(size, "ii", &width, &height)) { + Py_XDECREF(size); + return -1; + } + Py_DECREF(size); + + NSRect rect = NSMakeRect( /* x */ 100, /* y */ 350, width, height); + + Window* window = [[Window alloc] initWithContentRect: rect + styleMask: NSWindowStyleMaskTitled + | NSWindowStyleMaskClosable + | NSWindowStyleMaskResizable + | NSWindowStyleMaskMiniaturizable + backing: NSBackingStoreBuffered + defer: YES]; + [window setDelegate: figureCanvas]; + [window makeFirstResponder: figureCanvas]; + [window setReleasedWhenClosed:NO]; + [[window contentView] addSubview: figureCanvas]; + [figureCanvas updateDevicePixelRatio: [window backingScaleFactor]]; + + self->object = window; + [self->object setPyObject:(PyObject *)self]; + + if (!FigureWindowHashTable) { + FigureWindowHashTable = [NSHashTable weakObjectsHashTable]; + } + [FigureWindowHashTable addObject:window]; + + END_OBJC_ENTRY + return 0; +} + +static PyObject * __attribute__((unused)) +FigureManager__set_window_appearance(FigureManager *self, PyObject *args) +{ + BEGIN_OBJC_ENTRY + // Will be implemented + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +FigureManager__set_window_mode(FigureManager *self, PyObject *args) +{ + BEGIN_OBJC_ENTRY + const char *window_mode; + if (!PyArg_ParseTuple(args, "s", &window_mode) || !self->object) { + return NULL; + } + + NSString* window_mode_str = [NSString stringWithUTF8String: window_mode]; + if ([window_mode_str isEqualToString: @"tab"]) { + [self->object setTabbingMode: NSWindowTabbingModePreferred]; + } else if ([window_mode_str isEqualToString: @"window"]) { + [self->object setTabbingMode: NSWindowTabbingModeDisallowed]; + } else { // system settings + [self->object setTabbingMode: NSWindowTabbingModeAutomatic]; + } + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +FigureManager_repr(FigureManager *self) +{ + return PyUnicode_FromFormat("FigureManager<%p> wrapping Window<%p>", + (void *)self, (__bridge void *)self->object); +} + +static void +FigureManager__closeAndClearWindow(FigureManager *self) +{ + if (self->object) { + [self->object close]; + [self->object setDelegate:nil]; + [self->object setPyObject:NULL]; + [FigureWindowHashTable removeObject:self->object]; + self->object = nil; + + if ([FigureWindowHashTable count] == 0 && IsRunningFromShow) { + [NSApp stop:nil]; + } + } +} + +static void +FigureManager_dealloc(FigureManager *self) +{ + BEGIN_OBJC_ENTRY + FigureManager__closeAndClearWindow(self); + END_OBJC_ENTRY + Py_TYPE(self)->tp_free((PyObject *)self); +} + +static PyObject * +FigureManager__show(FigureManager *self) +{ + BEGIN_OBJC_ENTRY + [self->object makeKeyAndOrderFront: nil]; + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +FigureManager__raise(FigureManager *self) +{ + BEGIN_OBJC_ENTRY + [self->object orderFrontRegardless]; + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +FigureManager_destroy(FigureManager *self) +{ + BEGIN_OBJC_ENTRY + FigureManager__closeAndClearWindow(self); + + // call super(self, FigureManager).destroy() - it seems we need the + // explicit arguments, and just super() doesn't work in the C API. + PyObject *super_obj = PyObject_CallFunctionObjArgs( + (PyObject *)&PySuper_Type, + (PyObject *)&FigureManagerType, + self, + NULL + ); + if (super_obj == NULL) { + return NULL; // error + } + PyObject *result = PyObject_CallMethod(super_obj, "destroy", NULL); + Py_DECREF(super_obj); + if (result == NULL) { + return NULL; // error + } + Py_DECREF(result); + + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +FigureManager_set_icon(PyObject *null, PyObject *args) { + BEGIN_OBJC_ENTRY + PyObject* icon_path; + if (!PyArg_ParseTuple(args, "O&", &PyUnicode_FSDecoder, &icon_path)) { + return NULL; + } + const char* icon_path_ptr = PyUnicode_AsUTF8(icon_path); + if (!icon_path_ptr) { + Py_DECREF(icon_path); + return NULL; + } + + NSString* ns_icon_path = [NSString stringWithUTF8String: icon_path_ptr]; + Py_DECREF(icon_path); + if (!ns_icon_path) { + PyErr_SetString(PyExc_RuntimeError, "Could not convert to NSString*"); + return NULL; + } + NSImage* image = [[NSImage alloc] initByReferencingFile: ns_icon_path]; + if (!image) { + PyErr_SetString(PyExc_RuntimeError, "Could not create NSImage*"); + return NULL; + } + if (!image.valid) { + PyErr_SetString(PyExc_RuntimeError, "Image is not valid"); + return NULL; + } + + NSApplication* app = [NSApplication sharedApplication]; + app.applicationIconImage = image; + + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +FigureManager_set_window_title(FigureManager* self, + PyObject *args, PyObject *kwds) +{ + BEGIN_OBJC_ENTRY + const char* title; + if (!PyArg_ParseTuple(args, "s", &title)) { + return NULL; + } + // PyArg_ParseTuple "s" guarantees valid UTF-8, so stringWithUTF8String: will + // not return nil here; the nullable annotation is a false positive. + // NOLINTNEXTLINE(clang-analyzer-nullability.NullablePassedToNonnull) + [self->object setTitle: [NSString stringWithUTF8String: title]]; + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +FigureManager_get_window_title(FigureManager *self) +{ + BEGIN_OBJC_ENTRY + NSString *title = [self->object title]; + if (title) { + return PyUnicode_FromString([title UTF8String]); + } + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +FigureManager_resize(FigureManager *self, PyObject *args, PyObject *kwds) +{ + BEGIN_OBJC_ENTRY + int width, height; + if (!PyArg_ParseTuple(args, "ii", &width, &height)) { + return NULL; + } + Window* window = self->object; + if (window) { + CGFloat device_pixel_ratio = [window backingScaleFactor]; + width /= device_pixel_ratio; + height /= device_pixel_ratio; + // 36 comes from hard-coded size of toolbar later in code + [window setContentSize: NSMakeSize(width, height + 36.)]; + } + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +FigureManager_full_screen_toggle(FigureManager *self) +{ + BEGIN_OBJC_ENTRY + [self->object toggleFullScreen: nil]; + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyTypeObject FigureManagerType = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "matplotlib.backends._macosx.FigureManager", + .tp_doc = PyDoc_STR("A FigureManager object wraps a Cocoa NSWindow object."), + .tp_basicsize = sizeof(FigureManager), + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + + .tp_new = (newfunc)FigureManager_new, + .tp_init = (initproc)FigureManager_init, + .tp_dealloc = (destructor)FigureManager_dealloc, + .tp_repr = (reprfunc)FigureManager_repr, + + .tp_methods = (PyMethodDef[]){ // All docstrings are inherited. + {"_show", + (PyCFunction)FigureManager__show, + METH_NOARGS}, + {"_raise", + (PyCFunction)FigureManager__raise, + METH_NOARGS}, + {"destroy", + (PyCFunction)FigureManager_destroy, + METH_NOARGS}, + {"_set_window_mode", + (PyCFunction)FigureManager__set_window_mode, + METH_VARARGS, + PyDoc_STR("Set the window open mode (system, tab, window)")}, + {"set_icon", + (PyCFunction)FigureManager_set_icon, + METH_STATIC | METH_VARARGS, + PyDoc_STR("Set application icon")}, + {"set_window_title", + (PyCFunction)FigureManager_set_window_title, + METH_VARARGS}, + {"get_window_title", + (PyCFunction)FigureManager_get_window_title, + METH_NOARGS}, + {"resize", + (PyCFunction)FigureManager_resize, + METH_VARARGS}, + {"full_screen_toggle", + (PyCFunction)FigureManager_full_screen_toggle, + METH_NOARGS}, + {} // sentinel + }, +}; + + +#pragma mark - NavigationToolbar2 Type + +typedef struct { + PyObject_HEAD + __strong NSTextView *messagebox; + __strong MPLNavigationToolbar2 *object; + int height; +} NavigationToolbar2; + +static PyObject * +NavigationToolbar2_new(PyTypeObject *type, PyObject *args, PyObject *kwds) +{ + BEGIN_OBJC_ENTRY + lazy_init(); + NavigationToolbar2 *self = (NavigationToolbar2 *)type->tp_alloc(type, 0); + return (PyObject *)self; + END_OBJC_ENTRY + return NULL; +} + +static int +NavigationToolbar2_init(NavigationToolbar2 *self, PyObject *args, PyObject *kwds) +{ + BEGIN_OBJC_ENTRY + FigureCanvas *canvas; + const char* images[7]; + const char* tooltips[7]; + + const float gap = 2; + const int height = 36; + const int imagesize = 24; + + if (!PyArg_ParseTuple(args, "O!(sssssss)(sssssss)", + &FigureCanvasType, &canvas, + &images[0], &images[1], &images[2], &images[3], + &images[4], &images[5], &images[6], + &tooltips[0], &tooltips[1], &tooltips[2], &tooltips[3], + &tooltips[4], &tooltips[5], &tooltips[6])) { + return -1; + } + + MPLFigureCanvas *figureCanvas = canvas->object; + if (!figureCanvas) { + PyErr_SetString(PyExc_RuntimeError, "MPLFigureCanvas* is NULL"); + return -1; + } + + self->height = height; + + NSRect bounds = [figureCanvas bounds]; + NSWindow* window = [figureCanvas window]; + + bounds.origin.y += height; + [figureCanvas setFrame: bounds]; + + bounds.size.height += height; + [window setContentSize: bounds.size]; + + NSButton* buttons[7]; + SEL actions[7] = {@selector(home:), + @selector(back:), + @selector(forward:), + @selector(pan:), + @selector(zoom:), + @selector(configure_subplots:), + @selector(save_figure:)}; + NSButtonType buttontypes[7] = {NSButtonTypeMomentaryLight, + NSButtonTypeMomentaryLight, + NSButtonTypeMomentaryLight, + NSButtonTypePushOnPushOff, + NSButtonTypePushOnPushOff, + NSButtonTypeMomentaryLight, + NSButtonTypeMomentaryLight}; + + NSRect rect; + NSSize size; + NSSize scale; + + rect = NSMakeRect(0, 0, imagesize, imagesize); + rect = [window convertRectToBacking: rect]; + size = rect.size; + scale = NSMakeSize(imagesize / size.width, imagesize / size.height); + + rect.size.width = 32; + rect.size.height = 32; + rect.origin.x = gap; + rect.origin.y = 0.5*(height - rect.size.height); + + for (int i = 0; i < 7; i++) { + // PyArg_ParseTuple "s" guarantees valid UTF-8; stringWithUTF8String: will not return nil. + NSString* filename = [NSString stringWithUTF8String: images[i]]; + NSString* tooltip = [NSString stringWithUTF8String: tooltips[i]]; + // NOLINTNEXTLINE(clang-analyzer-nullability.NullablePassedToNonnull) + NSImage* image = [[NSImage alloc] initWithContentsOfFile: filename]; + buttons[i] = [[NSButton alloc] initWithFrame: rect]; + [image setSize: size]; + // Specify that it is a template image so the content tint + // color gets updated with the system theme (dark/light) + [image setTemplate: YES]; + [buttons[i] setBezelStyle: NSBezelStyleShadowlessSquare]; + [buttons[i] setButtonType: buttontypes[i]]; + [buttons[i] setImage: image]; + [buttons[i] scaleUnitSquareToSize: scale]; + [buttons[i] setImagePosition: NSImageOnly]; + [buttons[i] setToolTip: tooltip]; + [[window contentView] addSubview: buttons[i]]; + rect.origin.x += rect.size.width + gap; + } + + MPLNavigationToolbar2 *wrappedObject; + wrappedObject = [[MPLNavigationToolbar2 alloc] init]; + [wrappedObject setPyObject:(PyObject*)self]; + [wrappedObject installCallbacks: actions forButtons: buttons]; + + NSFont* font = [NSFont systemFontOfSize: 0.0]; + // rect.origin.x is now at the far right edge of the buttons + // we want the messagebox to take up the rest of the toolbar area + // Make it a zero-width box if we don't have enough room + rect.size.width = fmax(bounds.size.width - rect.origin.x, 0); + rect.origin.x = bounds.size.width - rect.size.width; + NSTextView* messagebox = [[NSTextView alloc] initWithFrame: rect]; + messagebox.textContainer.maximumNumberOfLines = 2; + messagebox.textContainer.lineBreakMode = NSLineBreakByTruncatingTail; + messagebox.alignment = NSTextAlignmentRight; + [messagebox setFont: font]; + [messagebox setDrawsBackground: NO]; + [messagebox setSelectable: NO]; + /* if selectable, the messagebox can become first responder, + * which is not supposed to happen */ + [[window contentView] addSubview: messagebox]; + [[window contentView] display]; + + self->object = wrappedObject; + self->messagebox = messagebox; + END_OBJC_ENTRY + return 0; +} + +static void +NavigationToolbar2_dealloc(NavigationToolbar2 *self) +{ + BEGIN_OBJC_ENTRY + [self->object setPyObject:NULL]; + self->object = nil; + self->messagebox = nil; + END_OBJC_ENTRY + Py_TYPE(self)->tp_free((PyObject*)self); +} + +static PyObject * +NavigationToolbar2_repr(NavigationToolbar2* self) +{ + return PyUnicode_FromFormat("NavigationToolbar2<%p> wrapping MPLNavigationToolbar2<%p>", + (void *)self, (__bridge void *)self->object); +} + +static PyObject * +NavigationToolbar2_set_message(NavigationToolbar2 *self, PyObject *args) +{ + BEGIN_OBJC_ENTRY + const char *message; + + if (!PyArg_ParseTuple(args, "s", &message)) { return NULL; } + + NSTextView* messagebox = self->messagebox; + + if (messagebox) { + // PyArg_ParseTuple "s" guarantees valid UTF-8; stringWithUTF8String: will not return nil. + NSString* text = [NSString stringWithUTF8String: message]; + // NOLINTNEXTLINE(clang-analyzer-nullability.NullablePassedToNonnull) + [messagebox setString: text]; + + // Adjust width and height with the window size and content + NSRect rectWindow = [messagebox.superview frame]; + NSRect rect = [messagebox frame]; + // Entire region to the right of the buttons + rect.size.width = rectWindow.size.width - rect.origin.x; + [messagebox setFrame: rect]; + // We want to control the vertical position of + // the rect by the content size to center it vertically + [messagebox.layoutManager ensureLayoutForTextContainer: messagebox.textContainer]; + NSRect contentRect = [messagebox.layoutManager usedRectForTextContainer: messagebox.textContainer]; + rect.origin.y = 0.5 * (self->height - contentRect.size.height); + rect.size.height = contentRect.size.height; + [messagebox setFrame: rect]; + // Disable cursorRects so that the cursor doesn't get updated by events + // in NSApp (like resizing TextViews), we want to handle the cursor + // changes from within MPL with set_cursor() ourselves + [[messagebox.superview window] disableCursorRects]; + } + + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyTypeObject NavigationToolbar2Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "matplotlib.backends._macosx.NavigationToolbar2", + .tp_doc = PyDoc_STR("NavigationToolbar2"), + .tp_basicsize = sizeof(NavigationToolbar2), + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + + .tp_new = (newfunc)NavigationToolbar2_new, + .tp_init = (initproc)NavigationToolbar2_init, + .tp_dealloc = (destructor)NavigationToolbar2_dealloc, + .tp_repr = (reprfunc)NavigationToolbar2_repr, + + .tp_methods = (PyMethodDef[]){ // All docstrings are inherited. + {"set_message", + (PyCFunction)NavigationToolbar2_set_message, + METH_VARARGS}, + {} // sentinel + }, +}; + +static PyObject * +choose_save_file(PyObject *unused, PyObject *args) +{ + BEGIN_OBJC_ENTRY + + int result; + const char* title; + const char* directory; + const char* default_filename; + if (!PyArg_ParseTuple(args, "sss", &title, &directory, &default_filename)) { + return NULL; + } + NSSavePanel* panel = [NSSavePanel savePanel]; + [panel setTitle: [NSString stringWithUTF8String: title]]; + // PyArg_ParseTuple "s" guarantees valid UTF-8; stringWithUTF8String: will not return nil. + // NOLINTNEXTLINE(clang-analyzer-nullability.NullablePassedToNonnull) + [panel setDirectoryURL: [NSURL fileURLWithPath: [NSString stringWithUTF8String: directory] + isDirectory: YES]]; + [panel setNameFieldStringValue: [NSString stringWithUTF8String: default_filename]]; + result = [panel runModal]; + if (result == NSModalResponseOK) { + NSString *filename = [[panel URL] path]; + if (!filename) { + PyErr_SetString(PyExc_RuntimeError, "Failed to obtain filename"); + return 0; + } + return PyUnicode_FromString([filename UTF8String]); + } + + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +show(PyObject *self) +{ + BEGIN_OBJC_ENTRY + + // Iterating over -[NSApp windows] will add the windows to the topmost + // autorelease pool, wrap in @autoreleasepool as -[NSApp run] is long-running. + @autoreleasepool { + [NSApp activateIgnoringOtherApps: YES]; + + for (NSWindow *window in [FigureWindowHashTable allObjects]) { + [window orderFront:nil]; + } + } + + Py_BEGIN_ALLOW_THREADS + IsRunningFromShow = YES; + [NSApp run]; + IsRunningFromShow = NO; + Py_END_ALLOW_THREADS + + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + + +#pragma mark - Timer Type + +typedef struct { + PyObject_HEAD + __strong NSTimer *timer; + BOOL shouldInvalidate; +} Timer; + +static PyObject * +Timer_new(PyTypeObject *type, PyObject *args, PyObject *kwds) +{ + BEGIN_OBJC_ENTRY + lazy_init(); + Timer *self = (Timer *)type->tp_alloc(type, 0); + return (PyObject *)self; + END_OBJC_ENTRY + return NULL; +} + +static PyObject * +Timer_repr(Timer *self) +{ + return PyUnicode_FromFormat("Timer<%p> wrapping NSTimer<%p>", + (void *)self, (__bridge void *)self->timer); +} + +static void +Timer__timer_stop_impl(Timer *self) +{ + if (self->shouldInvalidate) { + [self->timer invalidate]; + self->shouldInvalidate = NO; + } + self->timer = nil; +} + +static PyObject * +Timer__timer_start(Timer *self, PyObject *args) +{ + BEGIN_OBJC_ENTRY + NSTimer *timer; + NSTimeInterval interval; + PyObject *py_interval = NULL, *py_single = NULL, *py_on_timer = NULL; + int single; + if (!(py_interval = PyObject_GetAttrString((PyObject *)self, "_interval")) + || ((void)((interval = PyFloat_AsDouble(py_interval) / 1000.)), PyErr_Occurred()) + || !(py_single = PyObject_GetAttrString((PyObject *)self, "_single")) + || ((single = PyObject_IsTrue(py_single)) == -1) + || !(py_on_timer = PyObject_GetAttrString((PyObject *)self, "_on_timer"))) { + goto exit; + } + if (!PyMethod_Check(py_on_timer)) { + PyErr_SetString(PyExc_RuntimeError, "_on_timer should be a Python method"); + goto exit; + } + + // Stop any previous timers if start() was called multiple times + Timer__timer_stop_impl(self); + + // hold a reference to the timer so we can invalidate/stop it later + timer = [NSTimer timerWithTimeInterval: interval + repeats: !single + block: ^(NSTimer *timer) { + gil_call_method((PyObject *)self, "_on_timer"); + if (single) { + // A single-shot timer will be automatically invalidated when it fires, so + // we shouldn't do it ourselves when the object is deleted. + self->shouldInvalidate = NO; + } + }]; + + // Schedule the timer on the main run loop which is needed + // when updating the UI from a background thread + [[NSRunLoop mainRunLoop] addTimer: timer forMode: NSRunLoopCommonModes]; + + self->timer = timer; + self->shouldInvalidate = YES; + +exit: + Py_XDECREF(py_interval); + Py_XDECREF(py_single); + Py_XDECREF(py_on_timer); + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +Timer__timer_stop(Timer *self) +{ + BEGIN_OBJC_ENTRY + Timer__timer_stop_impl(self); + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static void +Timer_dealloc(Timer *self) +{ + BEGIN_OBJC_ENTRY + Timer__timer_stop_impl(self); + END_OBJC_ENTRY + Py_TYPE(self)->tp_free((PyObject *)self); +} + +static PyTypeObject TimerType = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "matplotlib.backends._macosx.Timer", + .tp_doc = PyDoc_STR("A Timer object that contains an NSTimer that gets added to " + "the event loop when started."), + .tp_basicsize = sizeof(Timer), + .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, + + .tp_new = (newfunc)Timer_new, + .tp_dealloc = (destructor)Timer_dealloc, + .tp_repr = (reprfunc)Timer_repr, + + .tp_methods = (PyMethodDef[]){ // All docstrings are inherited. + {"_timer_start", + (PyCFunction)Timer__timer_start, + METH_VARARGS}, + {"_timer_stop", + (PyCFunction)Timer__timer_stop, + METH_NOARGS}, + {} // sentinel + }, +}; + +static struct PyModuleDef moduledef = { + .m_base = PyModuleDef_HEAD_INIT, + .m_name = "_macosx", + .m_doc = PyDoc_STR("Mac OS X native backend"), + .m_size = -1, + .m_methods = (PyMethodDef[]){ + {"event_loop_is_running", + (PyCFunction)event_loop_is_running, + METH_NOARGS, + PyDoc_STR( + "Return whether the macosx backend has set up the NSApp main event loop.")}, + {"wake_on_fd_write", + (PyCFunction)wake_on_fd_write, + METH_VARARGS, + PyDoc_STR( + "Arrange for Python to invoke its signal handlers when (any) data is\n" + "written on the file descriptor given as argument.")}, + {"stop", + (PyCFunction)stop, + METH_VARARGS, + PyDoc_STR("Stop the NSApp.")}, + {"show", + (PyCFunction)show, + METH_NOARGS, + PyDoc_STR( + "Show all the figures and enter the main loop.\n" + "\n" + "This function does not return until all Matplotlib windows are closed,\n" + "and is normally not needed in interactive sessions.")}, + {"choose_save_file", + (PyCFunction)choose_save_file, + METH_VARARGS, + PyDoc_STR("Query the user for a location where to save a file.")}, + {} /* Sentinel */ + }, +}; + +#pragma GCC visibility push(default) + +PyMODINIT_FUNC +PyInit__macos(void) +{ + PyObject *m; + if (!(m = PyModule_Create(&moduledef)) + || PyModule_AddType(m, &FigureCanvasType) + || PyModule_AddType(m, &FigureManagerType) + || PyModule_AddType(m, &NavigationToolbar2Type) + || PyModule_AddType(m, &TimerType)) { + Py_XDECREF(m); + return NULL; + } +#ifdef Py_GIL_DISABLED + PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); +#endif + return m; +} + +#pragma GCC visibility pop diff --git a/src/macos/meson.build b/src/macos/meson.build new file mode 100644 index 000000000000..4e10d8486d00 --- /dev/null +++ b/src/macos/meson.build @@ -0,0 +1,18 @@ + +add_languages('objc', native: false) +py3.extension_module( + '_macos', + sources: files( + '_macos.m', + 'MPLAppDelegate.m', + 'MPLFigureCanvas.m', + 'MPLFigureManager.m', + 'MPLNavigationToolbar2.m', + 'MPLUtils.m', + ), + dependencies: dependency('appleframeworks', modules: 'Cocoa'), + override_options: ['werror=true'], + objc_args: ['-fobjc-arc'], + install: true, + subdir: 'matplotlib/backends', +) diff --git a/src/meson.build b/src/meson.build index c9cea13bb82c..3b9eb27c578f 100644 --- a/src/meson.build +++ b/src/meson.build @@ -148,4 +148,6 @@ if get_option('macosx') and host_machine.system() == 'darwin' objc_args: ['-fobjc-arc'], install: true, ) + + subdir('macos') endif From beb44adf112927b8a9b41d638e78ae7ea1dc8423 Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Thu, 30 Jul 2026 20:28:52 -0700 Subject: [PATCH 03/20] Global symbol renames in _legacymac.m Obj-C classes: MatplotlibAppDelegate -> MPLLegacyAppDelegate Window -> MPLLegacyWindow View -> MPLLegacyView NavigationToolbar2Handler -> MPLLegacyNavigationToolbar2Handler --- src/_legacymac.m | 66 ++++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/src/_legacymac.m b/src/_legacymac.m index 5b227fa1281a..6b3df4b5ff55 100755 --- a/src/_legacymac.m +++ b/src/_legacymac.m @@ -159,16 +159,16 @@ static int wait_for_stdin() { } /* ---------------------------- Cocoa classes ---------------------------- */ -@interface MatplotlibAppDelegate : NSObject +@interface MPLLegacyAppDelegate : NSObject - (BOOL)applicationSupportsSecureRestorableState:(NSApplication *)app; @end -@interface Window : NSWindow +@interface MPLLegacyWindow : NSWindow - (NSRect)constrainFrameRect:(NSRect)rect toScreen:(NSScreen*)screen; @property (nonatomic, assign) PyObject* manager; @end -@interface View : NSView +@interface MPLLegacyView : NSView { NSRect rubberband; @public double device_scale; } @@ -250,7 +250,7 @@ static void lazy_init(void) { NSApp = [NSApplication sharedApplication]; [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; - appDelegate = [[MatplotlibAppDelegate alloc] init]; + appDelegate = [[MPLLegacyAppDelegate alloc] init]; [NSApp setDelegate:appDelegate]; // Run our own event loop while waiting for stdin on the Python side @@ -372,7 +372,7 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) typedef struct { PyObject_HEAD - __strong View* view; + __strong MPLLegacyView* view; } FigureCanvas; static PyTypeObject FigureCanvasType; @@ -394,7 +394,7 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) FigureCanvas_init(FigureCanvas *self, PyObject *args, PyObject *kwds) { BEGIN_OBJC_ENTRY - View *view; + MPLLegacyView *view; NSTrackingArea *trackingArea; PyObject *builtins = NULL, *super_obj = NULL, @@ -414,7 +414,7 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) goto exit; } NSRect rect = NSMakeRect(0.0, 0.0, width, height); - view = [[View alloc] initWithFrame: rect]; + view = [[MPLLegacyView alloc] initWithFrame: rect]; view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; int opts = (NSTrackingMouseEnteredAndExited | NSTrackingMouseMoved | NSTrackingActiveInKeyWindow | NSTrackingInVisibleRect); @@ -511,7 +511,7 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) FigureCanvas_set_rubberband(FigureCanvas* self, PyObject *args) { BEGIN_OBJC_ENTRY - View* view = self->view; + MPLLegacyView* view = self->view; if (!view) { PyErr_SetString(PyExc_RuntimeError, "NSView* is NULL"); return NULL; @@ -644,7 +644,7 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) typedef struct { PyObject_HEAD - __strong Window* window; + __strong MPLLegacyWindow* window; } FigureManager; static PyObject* @@ -680,7 +680,7 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) return -1; } - View* view = ((FigureCanvas*)canvas)->view; + MPLLegacyView* view = ((FigureCanvas*)canvas)->view; if (!view) { /* Something really weird going on */ PyErr_SetString(PyExc_RuntimeError, "NSView* is NULL"); return -1; @@ -696,13 +696,13 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) NSRect rect = NSMakeRect( /* x */ 100, /* y */ 350, width, height); - Window* window = [[Window alloc] initWithContentRect: rect - styleMask: NSWindowStyleMaskTitled - | NSWindowStyleMaskClosable - | NSWindowStyleMaskResizable - | NSWindowStyleMaskMiniaturizable - backing: NSBackingStoreBuffered - defer: YES]; + MPLLegacyWindow* window = [[MPLLegacyWindow alloc] initWithContentRect: rect + styleMask: NSWindowStyleMaskTitled + | NSWindowStyleMaskClosable + | NSWindowStyleMaskResizable + | NSWindowStyleMaskMiniaturizable + backing: NSBackingStoreBuffered + defer: YES]; [window setDelegate: view]; [window setManager: (PyObject*)self]; [window makeFirstResponder: view]; @@ -893,7 +893,7 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) if (!PyArg_ParseTuple(args, "ii", &width, &height)) { return NULL; } - Window* window = self->window; + MPLLegacyWindow* window = self->window; if (window) { CGFloat device_pixel_ratio = [window backingScaleFactor]; width /= device_pixel_ratio; @@ -960,13 +960,13 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name) }, }; -@implementation MatplotlibAppDelegate +@implementation MPLLegacyAppDelegate - (BOOL)applicationSupportsSecureRestorableState:(NSApplication *)app { return YES; } @end -@interface NavigationToolbar2Handler : NSObject +@interface MPLLegacyNavigationToolbar2Handler : NSObject - (void)installCallbacks:(SEL[7])actions forButtons:(__strong NSButton*[7])buttons; - (void)home:(id)sender; - (void)back:(id)sender; @@ -983,11 +983,11 @@ - (void)save_figure:(id)sender; typedef struct { PyObject_HEAD __strong NSTextView* messagebox; - __strong NavigationToolbar2Handler* handler; + __strong MPLLegacyNavigationToolbar2Handler* handler; int height; } NavigationToolbar2; -@implementation NavigationToolbar2Handler +@implementation MPLLegacyNavigationToolbar2Handler - (void)installCallbacks:(SEL[7])actions forButtons:(__strong NSButton*[7])buttons { @@ -1053,7 +1053,7 @@ -(void)save_figure:(id)sender { gil_call_method(_toolbar, "save_figure"); } return -1; } - View* view = canvas->view; + MPLLegacyView* view = canvas->view; if (!view) { PyErr_SetString(PyExc_RuntimeError, "NSView* is NULL"); return -1; @@ -1121,8 +1121,8 @@ -(void)save_figure:(id)sender { gil_call_method(_toolbar, "save_figure"); } rect.origin.x += rect.size.width + gap; } - NavigationToolbar2Handler *handler; - handler = [[NavigationToolbar2Handler alloc] init]; + MPLLegacyNavigationToolbar2Handler *handler; + handler = [[MPLLegacyNavigationToolbar2Handler alloc] init]; [handler setToolbar:(PyObject*)self]; [handler installCallbacks: actions forButtons: buttons]; @@ -1259,7 +1259,7 @@ -(void)save_figure:(id)sender { gil_call_method(_toolbar, "save_figure"); } RETURN_NULL_OR_NONE } -@implementation Window +@implementation MPLLegacyWindow - (NSRect)constrainFrameRect:(NSRect)rect toScreen:(NSScreen*)screen { @@ -1273,7 +1273,7 @@ - (NSRect)constrainFrameRect:(NSRect)rect toScreen:(NSScreen*)screen @end -@implementation View +@implementation MPLLegacyView - (instancetype)initWithFrame:(NSRect)rect { if (self = [super initWithFrame: rect]) { @@ -1423,7 +1423,7 @@ - (void)updateDevicePixelRatio:(double)scale - (void)windowDidChangeBackingProperties:(NSNotification *)notification { - Window* window = [notification object]; + MPLLegacyWindow* window = [notification object]; [self updateDevicePixelRatio: [window backingScaleFactor]]; } @@ -1431,7 +1431,7 @@ - (void)windowDidChangeBackingProperties:(NSNotification *)notification - (void)windowDidResize: (NSNotification*)notification { int width, height; - Window* window = [notification object]; + MPLLegacyWindow* window = [notification object]; NSSize size = [[window contentView] frame].size; NSRect rect = [self frame]; @@ -1454,8 +1454,8 @@ - (void)windowWillClose:(NSNotification*)notification { // A view should not be the delegate of a window, this check // will go away with next refactor - Window *window = (Window *)[self window]; - if ([window isKindOfClass:[Window class]]) { + MPLLegacyWindow *window = (MPLLegacyWindow *)[self window]; + if ([window isKindOfClass:[MPLLegacyWindow class]]) { gil_call_method([window manager], "_handle_window_will_close"); } } @@ -1464,8 +1464,8 @@ - (BOOL)windowShouldClose:(NSNotification*)notification { // A view should not be the delegate of a window, this check // will go away with next refactor - Window *window = (Window *)[self window]; - if ([window isKindOfClass:[Window class]]) { + MPLLegacyWindow *window = (MPLLegacyWindow *)[self window]; + if ([window isKindOfClass:[MPLLegacyWindow class]]) { gil_call_method([window manager], "_handle_window_should_close"); } return YES; From 728186fc3c3a5e544c45983abbaaa86d846284e0 Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Thu, 30 Jul 2026 20:31:26 -0700 Subject: [PATCH 04/20] Make process_event static --- src/_legacymac.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_legacymac.m b/src/_legacymac.m index 6b3df4b5ff55..b7e99fe33447 100755 --- a/src/_legacymac.m +++ b/src/_legacymac.m @@ -216,7 +216,7 @@ static void gil_call_method(PyObject* obj, const char* name) PyGILState_Release(gstate); } -void process_event(char const* cls_name, char const* fmt, ...) +static void process_event(char const* cls_name, char const* fmt, ...) { PyGILState_STATE gstate = PyGILState_Ensure(); PyObject* module = NULL, * cls = NULL, From 49cd061cf0ce167b42cb04f795171a4b331f401f Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Fri, 31 Jul 2026 11:33:35 -0700 Subject: [PATCH 05/20] Migrate to reworked implementation --- lib/matplotlib/backends/backend_macos.py | 209 ++--- lib/matplotlib/backends/backend_macosx.py | 2 +- lib/matplotlib/mpl-data/matplotlibrc | 5 + lib/matplotlib/rcsetup.py | 20 +- lib/matplotlib/typing.py | 2 + src/macos/MPLAppDelegate.h | 5 + src/macos/MPLAppDelegate.m | 127 ++- src/macos/MPLFigureCanvas.h | 56 +- src/macos/MPLFigureCanvas.m | 860 +++++++++------------ src/macos/MPLFigureManager.h | 32 +- src/macos/MPLFigureManager.m | 203 ++++- src/macos/MPLNavigationToolbar2.h | 38 +- src/macos/MPLNavigationToolbar2.m | 186 ++++- src/macos/MPLUtils.h | 77 +- src/macos/MPLUtils.m | 240 +++++- src/macos/_macos.m | 891 +++++++++------------- src/macos/meson.build | 51 +- 17 files changed, 1787 insertions(+), 1217 deletions(-) diff --git a/lib/matplotlib/backends/backend_macos.py b/lib/matplotlib/backends/backend_macos.py index 26d09ac75d16..987be193ea3c 100644 --- a/lib/matplotlib/backends/backend_macos.py +++ b/lib/matplotlib/backends/backend_macos.py @@ -1,4 +1,5 @@ import os +import functools import matplotlib as mpl from matplotlib import _api, cbook @@ -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): @@ -21,91 +23,111 @@ 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. @@ -113,16 +135,21 @@ def start_event_loop(self, timeout=0): 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): @@ -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 @@ -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(): @@ -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 @@ -194,6 +233,6 @@ def show(self): @_Backend.export class _BackendMac(_Backend): - FigureCanvas = FigureCanvasMac + FigureCanvas = FigureCanvasMacAgg FigureManager = FigureManagerMac mainloop = FigureManagerMac.start_main_loop diff --git a/lib/matplotlib/backends/backend_macosx.py b/lib/matplotlib/backends/backend_macosx.py index 15f493be5375..99f92919ee47 100644 --- a/lib/matplotlib/backends/backend_macosx.py +++ b/lib/matplotlib/backends/backend_macosx.py @@ -18,4 +18,4 @@ class _BackendMac(_Backend): FigureCanvas = _legacymac.FigureCanvasLegacyMac FigureManager = _legacymac.FigureManagerLegacyMac - mainloop = _legacymac.FigureManagerLegacyMac.start_main_loop \ No newline at end of file + mainloop = _legacymac.FigureManagerLegacyMac.start_main_loop diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 3b8d222bb3a0..80366e369593 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -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 diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 4208c74d86f1..ffcb9283bee4 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -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, @@ -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 @@ -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", diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py index 70a61702c5c3..e5bc9ce45269 100644 --- a/lib/matplotlib/typing.py +++ b/lib/matplotlib/typing.py @@ -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", diff --git a/src/macos/MPLAppDelegate.h b/src/macos/MPLAppDelegate.h index d19281c18656..5a2c83372966 100644 --- a/src/macos/MPLAppDelegate.h +++ b/src/macos/MPLAppDelegate.h @@ -1,4 +1,9 @@ #import +NS_ASSUME_NONNULL_BEGIN + @interface MPLAppDelegate : NSObject + @end + +NS_ASSUME_NONNULL_END diff --git a/src/macos/MPLAppDelegate.m b/src/macos/MPLAppDelegate.m index 679d829d552e..0b60d3a5b8da 100644 --- a/src/macos/MPLAppDelegate.m +++ b/src/macos/MPLAppDelegate.m @@ -1,8 +1,133 @@ #import "MPLAppDelegate.h" +#import "MPLUtils.h" + + +// These are standard selectors which AppKit never exposes in any header. +// They are typically shown only in Interface Builder as a potential action. +@interface NSObject () +- (void) closeAll:(id)sender; +- (void) undo:(id)sender; +- (void) redo:(id)sender; +@end @implementation MPLAppDelegate -- (BOOL)applicationSupportsSecureRestorableState:(NSApplication *)app { + +#pragma mark - Lifecycle + +- (instancetype) init +{ + if ((self = [super init])) { + MPLLog("[Lifecycle] MPLAppDelegate<%p> init", self); + } + + return self; +} + + +- (void) dealloc +{ + MPLLog("[Lifecycle] MPLAppDelegate<%p> dealloc", self); +} + + +#pragma mark - Superclass Overrides + +- (BOOL) applicationSupportsSecureRestorableState:(NSApplication *)app +{ return YES; } + +- (void) applicationWillFinishLaunching:(NSNotification *)notification +{ + [self _buildMainMenu]; + + [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; +} + + +#pragma mark - Private Methods + +- (void) _buildMainMenu +{ + NSEventModifierFlags command = NSEventModifierFlagCommand; + NSEventModifierFlags optionCommand = NSEventModifierFlagOption | command; + NSEventModifierFlags shiftCommand = NSEventModifierFlagShift | command; + + __block NSMenu *currentMenu; + __block NSMenuItem *currentItem; + + NSMenu *mainMenu = [[NSMenu alloc] init]; + + __auto_type menu = ^(NSString *title) { + NSMenu *menu = [[NSMenu alloc] init]; + + NSMenuItem *menuItem = [[NSMenuItem alloc] init]; + [menuItem setTitle:title]; + [menuItem setSubmenu:menu]; + [menuItem setTarget:menu]; + [menuItem setAction:@selector(submenuAction:)]; + [mainMenu addItem:menuItem]; + + currentMenu = menu; + }; + + __auto_type item = ^(NSString *title, NSEventModifierFlags flags, NSString *keyEquivalent, SEL action) { + NSMenuItem *item = [[NSMenuItem alloc] init]; + + [item setTitle:title]; + [item setKeyEquivalent:keyEquivalent]; + [item setKeyEquivalentModifierMask:flags]; + [item setAction:action]; + + [currentMenu addItem:item]; + + currentItem = item; + }; + + __auto_type separator = ^() { + [currentMenu addItem:[NSMenuItem separatorItem]]; + }; + + menu(@"Matplotlib"); + item(@"Hide Matplotlib", command, @"h", @selector(hide:)); + item(@"Hide Others", optionCommand, @"h", @selector(hideOtherApplications:)); + item(@"Show All", 0, @"", @selector(unhideAllApplications:)); + separator(); + item(@"Quit Matplotlib", command, @"q", @selector(terminate:)); + + menu(@"File"); + item(@"Close", command, @"w", @selector(performClose:)); + item(@"Close All", optionCommand, @"w", @selector(closeAll:)); + [currentItem setTarget:NSApp]; + [currentItem setAlternate:YES]; + + menu(@"Edit"); + item(@"Undo", command, @"z", @selector(undo:)); + item(@"Redo", shiftCommand, @"z", @selector(redo:)); + separator(); + item(@"Cut", command, @"x", @selector(cut:)); + item(@"Copy", command, @"c", @selector(copy:)); + item(@"Paste", command, @"v", @selector(paste:)); + item(@"Delete", 0, @"", @selector(delete:)); + item(@"Select All", command, @"a", @selector(selectAll:)); + + menu(@"Window"); + item(@"Minimize", command, @"m", @selector(performMiniaturize:)); + item(@"Zoom", 0, @"", @selector(performZoom:)); + separator(); + item(@"Bring All to Front", 0, @"", @selector(arrangeInFront:)); + [NSApp setWindowsMenu:currentMenu]; + + menu(@"Help"); + [NSApp setHelpMenu:currentMenu]; + + [NSApp setMainMenu:mainMenu]; + + for (NSWindow *window in [NSApp windows]) { + [NSApp addWindowsItem:window title:[window title] filename:NO]; + } +} + + @end diff --git a/src/macos/MPLFigureCanvas.h b/src/macos/MPLFigureCanvas.h index 7fa55c230769..eebc769f5d53 100644 --- a/src/macos/MPLFigureCanvas.h +++ b/src/macos/MPLFigureCanvas.h @@ -1,37 +1,27 @@ #import #import -/* Keep track of the current mouse up/down state for open/closed cursor hand */ -extern bool mpl_leftMouseGrabbing; - -@interface MPLFigureCanvas : NSView -{ NSRect rubberband; - @public double device_scale; -} -- (void)drawRect:(NSRect)rect; -- (void)updateDevicePixelRatio:(double)scale; -- (void)windowDidChangeBackingProperties:(NSNotification*)notification; -- (void)windowDidResize:(NSNotification*)notification; -- (instancetype)initWithFrame:(NSRect)rect; -- (void)mouseEntered:(NSEvent*)event; -- (void)mouseExited:(NSEvent*)event; -- (void)mouseDown:(NSEvent*)event; -- (void)mouseUp:(NSEvent*)event; -- (void)mouseDragged:(NSEvent*)event; -- (void)mouseMoved:(NSEvent*)event; -- (void)rightMouseDown:(NSEvent*)event; -- (void)rightMouseUp:(NSEvent*)event; -- (void)rightMouseDragged:(NSEvent*)event; -- (void)otherMouseDown:(NSEvent*)event; -- (void)otherMouseUp:(NSEvent*)event; -- (void)otherMouseDragged:(NSEvent*)event; -- (void)setRubberband:(NSRect)rect; -- (void)removeRubberband; -- (NSString*)convertKeyEvent:(NSEvent*)event; -- (void)keyDown:(NSEvent*)event; -- (void)keyUp:(NSEvent*)event; -- (void)scrollWheel:(NSEvent *)event; -- (BOOL)acceptsFirstResponder; -- (void)flagsChanged:(NSEvent*)event; -@property (nonatomic, assign) PyObject *pyObject; +NS_ASSUME_NONNULL_BEGIN + +@class MPLFigureManager; + +@interface MPLFigureCanvas : NSView + +- (instancetype) initWithFrame:(NSRect)rect; + +- (void) updateLayerContentsWithBuffer: (NSData *) buffer + deviceWidth: (size_t) deviceWidth + deviceHeight: (size_t) deviceHeight; + +- (void) updateCursorType:(int)cursorType; +- (void) updateRubberbandWithDeviceX0:(int)x0 y0:(int)y0 x1:(int)x1 y1:(int)y1; +- (void) removeRubberband; +- (void) requestDisplayLayerWithNeedsDraw:(BOOL)needsDraw; // Thread-safe + +@property (nonatomic, assign, nullable) PyObject *pyObject; + +@property (nonatomic, weak, nullable) MPLFigureManager *manager; + @end + +NS_ASSUME_NONNULL_END diff --git a/src/macos/MPLFigureCanvas.m b/src/macos/MPLFigureCanvas.m index 4097099254bb..9a16fb3e29b6 100644 --- a/src/macos/MPLFigureCanvas.m +++ b/src/macos/MPLFigureCanvas.m @@ -1,564 +1,410 @@ #import "MPLFigureCanvas.h" #import "MPLUtils.h" -#import "MPLFigureManager.h" -static void _buffer_release(void* info, const void* data, size_t size) { - PyGILState_STATE gstate = PyGILState_Ensure(); - PyBuffer_Release((Py_buffer *)info); - free(info); - PyGILState_Release(gstate); -} - +@interface MPLRubberbandView : NSView +@end -/* Keep track of modifier key states for flagsChanged - to keep track of press vs release */ -static bool lastCommand = false; -static bool lastControl = false; -static bool lastShift = false; -static bool lastOption = false; -static bool lastCapsLock = false; -/* Keep track of whether this specific key modifier was pressed or not */ -static bool keyChangeCommand = false; -static bool keyChangeControl = false; -static bool keyChangeShift = false; -static bool keyChangeOption = false; -static bool keyChangeCapsLock = false; -bool mpl_leftMouseGrabbing = false; +@implementation MPLRubberbandView -static CGFloat _get_device_scale(CGContextRef cr) +- (void) drawRect:(NSRect)dirtyRect { - CGSize pixelSize = CGContextConvertSizeToDeviceSpace(cr, CGSizeMake(1, 1)); - return pixelSize.width; -} + NSBezierPath *path = [NSBezierPath bezierPathWithRect:[self bounds]]; + CGFloat dashPattern[2] = { 3.0, 3.0 }; -bool mpl_check_button(bool present, PyObject* set, char const* name) { - PyObject* module = NULL, * cls = NULL, * button = NULL; - bool failed = ( - present - && (!(module = PyImport_ImportModule("matplotlib.backend_bases")) - || !(cls = PyObject_GetAttrString(module, "MouseButton")) - || !(button = PyObject_GetAttrString(cls, name)) - || PySet_Add(set, button))); - Py_XDECREF(module); - Py_XDECREF(cls); - Py_XDECREF(button); - return failed; -} + [path setLineDash:dashPattern count:2 phase:0]; + [[NSColor whiteColor] setStroke]; + [path stroke]; -PyObject* mpl_buttons() -{ - PyGILState_STATE gstate = PyGILState_Ensure(); - PyObject* set = NULL; - NSUInteger buttons = [NSEvent pressedMouseButtons]; - - if (!(set = PySet_New(NULL)) - || mpl_check_button(buttons & (1 << 0), set, "LEFT") - || mpl_check_button(buttons & (1 << 1), set, "RIGHT") - || mpl_check_button(buttons & (1 << 2), set, "MIDDLE") - || mpl_check_button(buttons & (1 << 3), set, "BACK") - || mpl_check_button(buttons & (1 << 4), set, "FORWARD")) { - Py_CLEAR(set); // On failure, return NULL with an exception set. - } - PyGILState_Release(gstate); - return set; + [path setLineDash:dashPattern count:2 phase:3]; + [[NSColor blackColor] setStroke]; + [path stroke]; } -bool mpl_check_modifier(bool present, PyObject* list, char const* name) -{ - PyObject* py_name = NULL; - bool failed = ( - present - && (!(py_name = PyUnicode_FromString(name)) - || (PyList_Append(list, py_name)))); - Py_XDECREF(py_name); - return failed; -} +@end -PyObject* mpl_modifiers(NSEvent* event) -{ - PyGILState_STATE gstate = PyGILState_Ensure(); - PyObject* list = NULL; - NSUInteger modifiers = [event modifierFlags]; - if (!(list = PyList_New(0)) - || mpl_check_modifier(modifiers & NSEventModifierFlagControl, list, "ctrl") - || mpl_check_modifier(modifiers & NSEventModifierFlagOption, list, "alt") - || mpl_check_modifier(modifiers & NSEventModifierFlagShift, list, "shift") - || mpl_check_modifier(modifiers & NSEventModifierFlagCommand, list, "cmd")) { - Py_CLEAR(list); // On failure, return NULL with an exception set. - } - PyGILState_Release(gstate); - return list; -} + +@interface MPLFigureCanvas () +@end @implementation MPLFigureCanvas { - // Private ivars will live here + BOOL _isLeftMouseDown; + BOOL _isHandCursorActive; + NSEventModifierFlags _previousModifierFlags; + MPLRubberbandView *_rubberbandView; + BOOL _needsDrawOnNextDisplayLayer; } - -#pragma mark - Lifecycle - - (instancetype) initWithFrame:(NSRect)rect { if (self = [super initWithFrame: rect]) { - rubberband = NSZeroRect; - device_scale = 1; + NSTrackingAreaOptions options = ( + NSTrackingMouseEnteredAndExited | NSTrackingMouseMoved | + NSTrackingActiveInKeyWindow | NSTrackingInVisibleRect + ); + + CALayer *layer = [CALayer layer]; + [layer setDelegate:self]; + [layer setContentsGravity:kCAGravityResize]; + [layer setBackgroundColor:[[NSColor whiteColor] CGColor]]; + [layer setOpaque:YES]; + + [self setLayer:layer]; + [self setWantsLayer:YES]; + + [self addTrackingArea:[[NSTrackingArea alloc] initWithRect: CGRectZero + options: options + owner: self + userInfo: nil]]; + + MPLLog("[Lifecycle] MPLFigureCanvas<%p> init", self); } + return self; } +- (void) dealloc +{ + MPLLog("[Lifecycle] MPLFigureCanvas<%p> dealloc", self); +} + + #pragma mark - Superclass Overrides -// This will become a -viewDidChangeBackingProperties override -- (void) updateDevicePixelRatio:(double)scale +- (void) viewDidChangeBackingProperties { - PyObject *change = NULL; - PyGILState_STATE gstate = PyGILState_Ensure(); + CGFloat scaleFactor = [[self window] backingScaleFactor]; + if (!scaleFactor) scaleFactor = 1; - device_scale = scale; + int width, height; + [self _getDeviceSizeWithSize:[self frame].size width:&width height:&height]; - if (!(change = PyObject_CallMethod(_pyObject, "_set_device_pixel_ratio", "d", device_scale))) { - PyErr_Print(); - goto exit; - } + MPLCallMethod(_pyObject, "_handle_view_did_change_backing_properties", "dii", + scaleFactor, width, height + ); +} - if (PyObject_IsTrue(change)) { - // Notify that there was a resize_event that took place - process_event( - "ResizeEvent", "{s:s, s:O}", - "name", "resize_event", "canvas", _pyObject); - gil_call_method(_pyObject, "draw_idle"); - [self setNeedsDisplay: YES]; - } -exit: - Py_XDECREF(change); +- (void) setFrameSize:(NSSize)newSize +{ + CGSize oldSize = [self frame].size; + [super setFrameSize:newSize]; - PyGILState_Release(gstate); + if (!CGSizeEqualToSize(oldSize, newSize)) { + [self _callHandleResize]; + } } --(void) drawRect:(NSRect)rect + +- (BOOL) acceptsFirstResponder { - PyObject* renderer = NULL; - PyObject* renderer_buffer = NULL; + return YES; +} - PyGILState_STATE gstate = PyGILState_Ensure(); - CGContextRef cr = [[NSGraphicsContext currentContext] CGContext]; +#pragma mark - CALayerDelegate - if (!(renderer = PyObject_CallMethod(_pyObject, "get_renderer", "")) - || !(renderer_buffer = PyObject_CallMethod(renderer, "buffer_rgba", ""))) { - PyErr_Print(); - goto exit; - } - if (_copy_agg_buffer(cr, renderer_buffer)) { - printf("copy_agg_buffer failed\n"); - goto exit; - } - if (!NSIsEmptyRect(rubberband)) { - // We use bezier paths so we can stroke the outside with a dash - // pattern alternating white/black with two separate paths offset - // in phase. - NSBezierPath *white_path = [NSBezierPath bezierPathWithRect: rubberband]; - NSBezierPath *black_path = [NSBezierPath bezierPathWithRect: rubberband]; - CGFloat dash_pattern[2] = {3, 3}; - [white_path setLineDash: dash_pattern count: 2 phase: 0]; - [black_path setLineDash: dash_pattern count: 2 phase: 3]; - [[NSColor whiteColor] setStroke]; - [white_path stroke]; - [[NSColor blackColor] setStroke]; - [black_path stroke]; - } +- (void) displayLayer:(CALayer *)layer +{ + int needsDraw = _needsDrawOnNextDisplayLayer ? 1 : 0; + MPLCallMethod(_pyObject, "_handle_display_layer", "i", needsDraw); +} - exit: - Py_XDECREF(renderer_buffer); - Py_XDECREF(renderer); - PyGILState_Release(gstate); +- (nullable id) actionForLayer:(CALayer *)layer forKey:(NSString *)event +{ + return [NSNull null]; } -// This becomes a -setFrameSize: override -- (void) windowDidResize:(NSNotification*)notification -{ - int width, height; - Window* window = [notification object]; - NSSize size = [[window contentView] frame].size; - NSRect rect = [self frame]; - size.height -= rect.origin.y; - width = size.width; - height = size.height; +#pragma mark - Private Methods - PyGILState_STATE gstate = PyGILState_Ensure(); - PyObject* result = PyObject_CallMethod( - _pyObject, "resize", "ii", width, height); - if (result) - Py_DECREF(result); - else - PyErr_Print(); - PyGILState_Release(gstate); - [self setNeedsDisplay: YES]; +- (void) _getDevicePointWithWindowLocation:(CGPoint)windowLocation x:(int *)outX y:(int *)outY +{ + CGPoint viewLocation = [self convertPoint:windowLocation fromView:nil]; + viewLocation = [self convertPointToBacking:viewLocation]; + + *outX = viewLocation.x; + *outY = viewLocation.y; } -- (BOOL) acceptsFirstResponder + +- (void) _getDevicePointWithEvent:(NSEvent *)event x:(int *)outX y:(int *)outY { - return YES; + NSPoint windowLocation = [event locationInWindow]; + + [self _getDevicePointWithWindowLocation:windowLocation x:outX y:outY]; } -#pragma mark - NSWindowDelegate +- (void) _getDeviceSizeWithSize:(CGSize)size width:(int *)outWidth height:(int *)outHeight +{ + CGSize deviceSize = [self convertSizeToBacking:size]; -// This goes away and we will use a -viewDidChangeBackingProperties override -- (void) windowDidChangeBackingProperties:(NSNotification *)notification + *outWidth = (int)deviceSize.width; + *outHeight = (int)deviceSize.height; +} + + +- (void) _callHandleResize { - Window *window = [notification object]; + int width, height; + [self _getDeviceSizeWithSize:[self frame].size width:&width height:&height]; - [self updateDevicePixelRatio: [window backingScaleFactor]]; + MPLCallMethod(_pyObject, "_handle_resize", "ii", width, height); } -// This gets moved to MPLFigureManager, which will be a NSWindowController subclass -- (void) windowWillClose:(NSNotification *)notification + +- (void) _updateHandCursor { - // A view should not be the delegate of a window, this check - // will go away with next refactor - Window *window = (Window *)[self window]; - if ([window isKindOfClass:[Window class]]) { - gil_call_method([window pyObject], "_handle_window_will_close"); + if (_isHandCursorActive) { + [(_isLeftMouseDown ? [NSCursor closedHandCursor] : [NSCursor openHandCursor]) set]; } } -// This gets moved to MPLFigureManager, which will be a NSWindowController subclass -- (BOOL) windowShouldClose:(NSNotification *)notification + +- (void) _updateRubberbandViewWithFrame:(CGRect)frame { - // A view should not be the delegate of a window, this check - // will go away with next refactor - Window *window = (Window *)[self window]; - if ([window isKindOfClass:[Window class]]) { - gil_call_method([window pyObject], "_handle_window_should_close"); + if (CGRectIsEmpty(frame)) { + [_rubberbandView removeFromSuperview]; + _rubberbandView = nil; + + } else if (!_rubberbandView) { + _rubberbandView = [[MPLRubberbandView alloc] initWithFrame:frame]; + [self addSubview:_rubberbandView]; + + } else { + [_rubberbandView setFrame:frame]; } - return YES; } #pragma mark - Keyboard Events -- (NSString *) convertKeyEvent:(NSEvent *)event +- (NSString *) _mappedStringWithCharacters:(NSString *)characters { - NSMutableString* returnkey = [NSMutableString string]; - if (keyChangeControl) { - // When control is the key that was pressed, return the full word - [returnkey appendString:@"control+"]; - } else if (([event modifierFlags] & NSEventModifierFlagControl)) { - // If control is already pressed, return the shortened version - [returnkey appendString:@"ctrl+"]; - } - if (([event modifierFlags] & NSEventModifierFlagOption) || keyChangeOption) { - [returnkey appendString:@"alt+" ]; - } - if (([event modifierFlags] & NSEventModifierFlagCommand) || keyChangeCommand) { - [returnkey appendString:@"cmd+" ]; - } - // Don't print caps_lock unless it was the key that got pressed - if (keyChangeCapsLock) { - [returnkey appendString:@"caps_lock+" ]; - } + NSDictionary *keyMap = @{ + @( NSLeftArrowFunctionKey ): @"left", @( NSRightArrowFunctionKey ): @"right", + @( NSUpArrowFunctionKey ): @"up", @( NSDownArrowFunctionKey ): @"down", + @( NSF1FunctionKey ): @"f1", @( NSF2FunctionKey ): @"f2", + @( NSF3FunctionKey ): @"f3", @( NSF4FunctionKey ): @"f4", + @( NSF5FunctionKey ): @"f5", @( NSF6FunctionKey ): @"f6", + @( NSF7FunctionKey ): @"f7", @( NSF8FunctionKey ): @"f8", + @( NSF9FunctionKey ): @"f9", @( NSF10FunctionKey ): @"f10", + @( NSF11FunctionKey ): @"f11", @( NSF12FunctionKey ): @"f12", + @( NSF13FunctionKey ): @"f13", @( NSF14FunctionKey ): @"f14", + @( NSF15FunctionKey ): @"f15", @( NSF16FunctionKey ): @"f16", + @( NSF17FunctionKey ): @"f17", @( NSF18FunctionKey ): @"f18", + @( NSF19FunctionKey ): @"f19", @( NSF20FunctionKey ): @"f20", + @( NSScrollLockFunctionKey ): @"scroll_lock", @( NSBreakFunctionKey ): @"break", + @( NSInsertFunctionKey ): @"insert", @( NSDeleteFunctionKey ): @"delete", + @( NSHomeFunctionKey ): @"home", @( NSEndFunctionKey ): @"end", + @( NSPageDownFunctionKey ): @"pagedown", @( NSPageUpFunctionKey ): @"pageup", + @( NSDeleteCharacter ): @"backspace", @( NSBackTabCharacter ): @"backtab", + @( NSEnterCharacter ): @"enter", @( NSTabCharacter ): @"tab", + @( NSCarriageReturnCharacter ): @"enter", + @( 27 ): @"escape" // No AppKit constant for Escape + }; + + return ([characters length] > 0) ? + [keyMap objectForKey:@( [characters characterAtIndex:0] )] : + nil; +} - // flagsChanged event can't handle charactersIgnoringModifiers - // because it was a modifier key that was pressed/released - if (event.type != NSEventTypeFlagsChanged) { - NSString* specialchar; - switch ([[event charactersIgnoringModifiers] characterAtIndex:0]) { - case NSLeftArrowFunctionKey: specialchar = @"left"; break; - case NSRightArrowFunctionKey: specialchar = @"right"; break; - case NSUpArrowFunctionKey: specialchar = @"up"; break; - case NSDownArrowFunctionKey: specialchar = @"down"; break; - case NSF1FunctionKey: specialchar = @"f1"; break; - case NSF2FunctionKey: specialchar = @"f2"; break; - case NSF3FunctionKey: specialchar = @"f3"; break; - case NSF4FunctionKey: specialchar = @"f4"; break; - case NSF5FunctionKey: specialchar = @"f5"; break; - case NSF6FunctionKey: specialchar = @"f6"; break; - case NSF7FunctionKey: specialchar = @"f7"; break; - case NSF8FunctionKey: specialchar = @"f8"; break; - case NSF9FunctionKey: specialchar = @"f9"; break; - case NSF10FunctionKey: specialchar = @"f10"; break; - case NSF11FunctionKey: specialchar = @"f11"; break; - case NSF12FunctionKey: specialchar = @"f12"; break; - case NSF13FunctionKey: specialchar = @"f13"; break; - case NSF14FunctionKey: specialchar = @"f14"; break; - case NSF15FunctionKey: specialchar = @"f15"; break; - case NSF16FunctionKey: specialchar = @"f16"; break; - case NSF17FunctionKey: specialchar = @"f17"; break; - case NSF18FunctionKey: specialchar = @"f18"; break; - case NSF19FunctionKey: specialchar = @"f19"; break; - case NSScrollLockFunctionKey: specialchar = @"scroll_lock"; break; - case NSBreakFunctionKey: specialchar = @"break"; break; - case NSInsertFunctionKey: specialchar = @"insert"; break; - case NSDeleteFunctionKey: specialchar = @"delete"; break; - case NSHomeFunctionKey: specialchar = @"home"; break; - case NSEndFunctionKey: specialchar = @"end"; break; - case NSPageDownFunctionKey: specialchar = @"pagedown"; break; - case NSPageUpFunctionKey: specialchar = @"pageup"; break; - case NSDeleteCharacter: specialchar = @"backspace"; break; - case NSEnterCharacter: specialchar = @"enter"; break; - case NSTabCharacter: specialchar = @"tab"; break; - case NSCarriageReturnCharacter: specialchar = @"enter"; break; - case NSBackTabCharacter: specialchar = @"backtab"; break; - case 27: specialchar = @"escape"; break; - default: specialchar = nil; - } - if (specialchar) { - if (([event modifierFlags] & NSEventModifierFlagShift) || keyChangeShift) { - [returnkey appendString:@"shift+"]; - } - [returnkey appendString:specialchar]; - } else { - // charactersIgnoringModifiers is nullable; guard defensively in case - // an unexpected event type reaches this path. - NSString* chars = [event charactersIgnoringModifiers]; - if (chars) { - [returnkey appendString:chars]; - } - } + +- (NSString *) _keyStringWithString: (nullable NSString *) characters + modifierFlags: (NSEventModifierFlags) flags + controlString: (NSString *) controlString +{ + NSMutableArray *array = [NSMutableArray array]; + + if (flags & NSEventModifierFlagControl ) [array addObject:controlString]; + if (flags & NSEventModifierFlagOption ) [array addObject:@"alt"]; + if (flags & NSEventModifierFlagCommand ) [array addObject:@"cmd"]; + if (flags & NSEventModifierFlagCapsLock ) [array addObject:@"caps_lock"]; + if (flags & NSEventModifierFlagShift ) [array addObject:@"shift"]; + + if (characters) [array addObject:characters]; + + return [array componentsJoinedByString:@"+"]; +} + + +- (void) _callHandleKeyWithKeyString:(NSString *)keyString isPress:(BOOL)isPress +{ + NSPoint windowLocation = [[self window] mouseLocationOutsideOfEventStream]; + + int x, y; + [self _getDevicePointWithWindowLocation:windowLocation x:&x y:&y]; + + const char *keyCString = [keyString UTF8String]; + if (!keyCString) return; + + MPLCallMethod(_pyObject, "_handle_key", "isii", (int)isPress, keyCString, x, y); +} + + +- (void) _handleKeyDownOrUp:(NSEvent *)event isPress:(BOOL)isPress +{ + NSEventModifierFlags flags = [event modifierFlags]; + NSString *characters = [event charactersIgnoringModifiers]; + NSString *mappedString = [self _mappedStringWithCharacters:characters]; + NSString *stringToUse; + + // -charactersIgnoringModifiers doesn't "ignore" the shift modifier so + // strip it from flags before calling -_keyString... + if (!mappedString) { + stringToUse = characters; + flags = flags & ~NSEventModifierFlagShift; } else { - if (([event modifierFlags] & NSEventModifierFlagShift) || keyChangeShift) { - [returnkey appendString:@"shift+"]; - } - // Since it was a modifier event trim the final character of the string - // because we added in "+" earlier - [returnkey setString: [returnkey substringToIndex:[returnkey length] - 1]]; + stringToUse = mappedString; } - return returnkey; + NSString *keyString = [self _keyStringWithString: stringToUse + modifierFlags: flags + controlString: @"ctrl"]; + + [self _callHandleKeyWithKeyString:keyString isPress:isPress]; } + - (void) keyDown:(NSEvent *)event { - const char* s = [[self convertKeyEvent: event] UTF8String]; - NSPoint location = [[self window] mouseLocationOutsideOfEventStream]; - location = [self convertPoint: location fromView: nil]; - int x = location.x * device_scale, - y = location.y * device_scale; - if (s) { - process_event( - "KeyEvent", "{s:s, s:O, s:s, s:i, s:i}", - "name", "key_press_event", "canvas", _pyObject, "key", s, "x", x, "y", y); - } else { - process_event( - "KeyEvent", "{s:s, s:O, s:O, s:i, s:i}", - "name", "key_press_event", "canvas", _pyObject, "key", Py_None, "x", x, "y", y); - } + [self _handleKeyDownOrUp:event isPress:YES]; } + - (void) keyUp:(NSEvent *)event { - const char* s = [[self convertKeyEvent: event] UTF8String]; - NSPoint location = [[self window] mouseLocationOutsideOfEventStream]; - location = [self convertPoint: location fromView: nil]; - int x = location.x * device_scale, - y = location.y * device_scale; - if (s) { - process_event( - "KeyEvent", "{s:s, s:O, s:s, s:i, s:i}", - "name", "key_release_event", "canvas", _pyObject, "key", s, "x", x, "y", y); - } else { - process_event( - "KeyEvent", "{s:s, s:O, s:O, s:i, s:i}", - "name", "key_release_event", "canvas", _pyObject, "key", Py_None, "x", x, "y", y); - } + [self _handleKeyDownOrUp:event isPress:NO]; } -// flagsChanged gets called whenever a modifier key is pressed OR released -// so we need to handle both cases here + - (void) flagsChanged:(NSEvent *)event { - bool isPress = false; // true if key is pressed, false if key was released - - // Each if clause tests the two cases for each of the keys we can handle - // 1. If the modifier flag "command key" is pressed and it was not previously - // 2. If the modifier flag "command key" is not pressed and it was previously - // !! converts the result of the bitwise & operator to a logical boolean, - // which allows us to then bitwise xor (^) the result with a boolean (lastCommand). - if (!!([event modifierFlags] & NSEventModifierFlagCommand) ^ lastCommand) { - // Command pressed/released - lastCommand = !lastCommand; - keyChangeCommand = true; - isPress = lastCommand; - } else if (!!([event modifierFlags] & NSEventModifierFlagControl) ^ lastControl) { - // Control pressed/released - lastControl = !lastControl; - keyChangeControl = true; - isPress = lastControl; - } else if (!!([event modifierFlags] & NSEventModifierFlagShift) ^ lastShift) { - // Shift pressed/released - lastShift = !lastShift; - keyChangeShift = true; - isPress = lastShift; - } else if (!!([event modifierFlags] & NSEventModifierFlagOption) ^ lastOption) { - // Option pressed/released - lastOption = !lastOption; - keyChangeOption = true; - isPress = lastOption; - } else if (!!([event modifierFlags] & NSEventModifierFlagCapsLock) ^ lastCapsLock) { - // Capslock pressed/released - lastCapsLock = !lastCapsLock; - keyChangeCapsLock = true; - isPress = lastCapsLock; - } else { - // flag we don't handle - return; + NSEventModifierFlags currentFlags = [event modifierFlags] & ( + NSEventModifierFlagControl | NSEventModifierFlagOption | NSEventModifierFlagCommand | + NSEventModifierFlagCapsLock | NSEventModifierFlagShift + ); + + if (currentFlags == _previousModifierFlags) return; + + NSString *keyString = [self _keyStringWithString: nil + modifierFlags: (currentFlags | _previousModifierFlags) + controlString: @"control"]; + + BOOL isPress = currentFlags > _previousModifierFlags; + [self _callHandleKeyWithKeyString:keyString isPress:isPress]; + + _previousModifierFlags = currentFlags; +} + + +#pragma mark - Mouse Events + +- (void) _handleMouseDownOrUp:(NSEvent *)event isPress:(BOOL)isPress +{ + NSInteger buttonNumber = [event buttonNumber]; + NSEventModifierFlags modifierFlags = [event modifierFlags]; + + int x, y; + [self _getDevicePointWithEvent:event x:&x y:&y]; + + if ([event type] == NSEventTypeLeftMouseDown) { + if (modifierFlags & NSEventModifierFlagControl) { + // emulate a right-button click + buttonNumber = 1; + + } else if (modifierFlags & NSEventModifierFlagOption) { + // emulate a middle-button click + buttonNumber = 2; + } + + _isLeftMouseDown = YES; + + [self _updateHandCursor]; + + } else if ([event type] == NSEventTypeLeftMouseUp) { + _isLeftMouseDown = NO; + + [self _updateHandCursor]; } + // Map AppKit buttonNumber to MPL MouseButton + buttonNumber++; + if (buttonNumber == 2) buttonNumber = 3; + else if (buttonNumber == 3) buttonNumber = 2; + if (isPress) { - [self keyDown:event]; + MPLCallMethod(_pyObject, "_handle_mouse_down", + "iilki", x, y, buttonNumber, modifierFlags, + (int)([event clickCount] == 2 ? 1 : 0) + ); } else { - [self keyUp:event]; + MPLCallMethod(_pyObject, "_handle_mouse_up", + "iilk", x, y, buttonNumber, modifierFlags + ); } - - // Reset the state for the key changes after handling the event - keyChangeCommand = false; - keyChangeControl = false; - keyChangeShift = false; - keyChangeOption = false; - keyChangeCapsLock = false; } -#pragma mark - Mouse Events - - (void) mouseEntered:(NSEvent *)event { int x, y; - NSPoint location = [event locationInWindow]; - location = [self convertPoint: location fromView: nil]; - x = location.x * device_scale; - y = location.y * device_scale; - process_event( - "LocationEvent", "{s:s, s:O, s:i, s:i, s:N}", - "name", "figure_enter_event", "canvas", _pyObject, "x", x, "y", y, - "modifiers", mpl_modifiers(event)); + [self _getDevicePointWithEvent:event x:&x y:&y]; + + MPLCallMethod(_pyObject, "_handle_mouse_entered", "iik", x, y, [event modifierFlags]); } + - (void) mouseExited:(NSEvent *)event { int x, y; - NSPoint location = [event locationInWindow]; - location = [self convertPoint: location fromView: nil]; - x = location.x * device_scale; - y = location.y * device_scale; - process_event( - "LocationEvent", "{s:s, s:O, s:i, s:i, s:N}", - "name", "figure_leave_event", "canvas", _pyObject, "x", x, "y", y, - "modifiers", mpl_modifiers(event)); + [self _getDevicePointWithEvent:event x:&x y:&y]; + + MPLCallMethod(_pyObject, "_handle_mouse_exited", "iik", x, y, [event modifierFlags]); } - (void) mouseMoved:(NSEvent *)event { int x, y; - NSPoint location = [event locationInWindow]; - location = [self convertPoint: location fromView: nil]; - x = location.x * device_scale; - y = location.y * device_scale; - process_event( - "MouseEvent", "{s:s, s:O, s:i, s:i, s:N, s:N}", - "name", "motion_notify_event", "canvas", _pyObject, "x", x, "y", y, - "buttons", mpl_buttons(), "modifiers", mpl_modifiers(event)); + [self _getDevicePointWithEvent:event x:&x y:&y]; + + MPLCallMethod(_pyObject, "_handle_mouse_moved", + "iikk", x, y, [NSEvent pressedMouseButtons], [event modifierFlags] + ); } + - (void) scrollWheel:(NSEvent *)event { - int step; - float d = [event deltaY]; - if (d > 0) { step = 1; } - else if (d < 0) { step = -1; } - else return; - NSPoint location = [event locationInWindow]; - NSPoint point = [self convertPoint: location fromView: nil]; - int x = (int)round(point.x * device_scale); - int y = (int)round(point.y * device_scale - 1); - process_event( - "MouseEvent", "{s:s, s:O, s:i, s:i, s:i, s:N}", - "name", "scroll_event", "canvas", _pyObject, - "x", x, "y", y, "step", step, "modifiers", mpl_modifiers(event)); -} - -- (void) mouseDown:(NSEvent *)event -{ - int x, y; - int button; - int dblclick = 0; - NSPoint location = [event locationInWindow]; - location = [self convertPoint: location fromView: nil]; - x = location.x * device_scale; - y = location.y * device_scale; - switch ([event type]) - { case NSEventTypeLeftMouseDown: - { unsigned int modifier = [event modifierFlags]; - if (modifier & NSEventModifierFlagControl) - /* emulate a right-button click */ - button = 3; - else if (modifier & NSEventModifierFlagOption) - /* emulate a middle-button click */ - button = 2; - else - { - button = 1; - if ([NSCursor currentCursor]==[NSCursor openHandCursor]) { - mpl_leftMouseGrabbing = true; - [[NSCursor closedHandCursor] set]; - } - } - break; - } - case NSEventTypeOtherMouseDown: button = 2; break; - case NSEventTypeRightMouseDown: button = 3; break; - default: return; /* Unknown mouse event */ - } - if ([event clickCount] == 2) { - dblclick = 1; - } - process_event( - "MouseEvent", "{s:s, s:O, s:i, s:i, s:i, s:i, s:N}", - "name", "button_press_event", "canvas", _pyObject, "x", x, "y", y, - "button", button, "dblclick", dblclick, "modifiers", mpl_modifiers(event)); -} + float deltaY = [event deltaY]; + int step = (deltaY > 0) - (deltaY < 0); // step = -1, 0, or 1 + if (step == 0) return; -- (void) mouseUp:(NSEvent *)event -{ - int button; int x, y; - NSPoint location = [event locationInWindow]; - location = [self convertPoint: location fromView: nil]; - x = location.x * device_scale; - y = location.y * device_scale; - switch ([event type]) - { case NSEventTypeLeftMouseUp: - mpl_leftMouseGrabbing = false; - button = 1; - if ([NSCursor currentCursor]==[NSCursor closedHandCursor]) - [[NSCursor openHandCursor] set]; - break; - case NSEventTypeOtherMouseUp: button = 2; break; - case NSEventTypeRightMouseUp: button = 3; break; - default: return; /* Unknown mouse event */ - } - process_event( - "MouseEvent", "{s:s, s:O, s:i, s:i, s:i, s:N}", - "name", "button_release_event", "canvas", _pyObject, "x", x, "y", y, - "button", button, "modifiers", mpl_modifiers(event)); + [self _getDevicePointWithEvent:event x:&x y:&y]; + + MPLCallMethod(_pyObject, "_handle_scroll_wheel", + "iiik", x, y, step, [event modifierFlags] + ); } -// Funnel other down/up events to -mouseDown: or -mouseUp: -- (void) rightMouseDown:(NSEvent *)event { [self mouseDown:event]; } -- (void) otherMouseDown:(NSEvent *)event { [self mouseDown:event]; } -- (void) rightMouseUp: (NSEvent *)event { [self mouseUp:event]; } -- (void) otherMouseUp: (NSEvent *)event { [self mouseUp:event]; } -// Funnel dragged events to -mouseMoved: +// Funnel all button events to -_handleMouseDownOrUp:isPress: +- (void) mouseDown: (NSEvent *)event { [self _handleMouseDownOrUp:event isPress:YES]; } +- (void) rightMouseDown:(NSEvent *)event { [self _handleMouseDownOrUp:event isPress:YES]; } +- (void) otherMouseDown:(NSEvent *)event { [self _handleMouseDownOrUp:event isPress:YES]; } +- (void) mouseUp: (NSEvent *)event { [self _handleMouseDownOrUp:event isPress:NO]; } +- (void) rightMouseUp: (NSEvent *)event { [self _handleMouseDownOrUp:event isPress:NO]; } +- (void) otherMouseUp: (NSEvent *)event { [self _handleMouseDownOrUp:event isPress:NO]; } + + +// Funnel dragged events to mouseMoved: - (void) mouseDragged: (NSEvent *)event { [self mouseMoved:event]; } - (void) rightMouseDragged:(NSEvent *)event { [self mouseMoved:event]; } - (void) otherMouseDragged:(NSEvent *)event { [self mouseMoved:event]; } @@ -566,89 +412,87 @@ - (void) otherMouseDragged:(NSEvent *)event { [self mouseMoved:event]; } #pragma mark - Public Methods -// This will become -updateLayerWithBuffer: -static int _copy_agg_buffer(CGContextRef cr, PyObject *renderer) +- (void) updateLayerContentsWithBuffer: (NSData *) buffer + deviceWidth: (size_t) deviceWidth + deviceHeight: (size_t) deviceHeight; { - Py_buffer *buffer = malloc(sizeof(Py_buffer)); + CFDataRef cfBuffer = (__bridge CFDataRef)buffer; + CGDataProviderRef provider = CGDataProviderCreateWithCFData(cfBuffer); - if (PyObject_GetBuffer(renderer, buffer, PyBUF_CONTIG_RO) == -1) { - PyErr_Print(); - return 1; + if (!provider) { + PyErr_SetString(PyExc_RuntimeError, "CGDataProviderCreateWithCFData() failed"); + return; } - if (buffer->ndim != 3 || buffer->shape[2] != 4) { - _buffer_release(buffer, NULL, 0); - return 1; - } + CGColorSpaceRef colorSpace = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); + CGBitmapInfo bitmapInfo = 0 | kCGBitmapByteOrderDefault | kCGImageAlphaLast; - const Py_ssize_t nrows = buffer->shape[0]; - const Py_ssize_t ncols = buffer->shape[1]; - const size_t bytesPerComponent = 1; - const size_t bitsPerComponent = 8 * bytesPerComponent; - const size_t nComponents = 4; /* red, green, blue, alpha */ - const size_t bitsPerPixel = bitsPerComponent * nComponents; - const size_t bytesPerRow = nComponents * bytesPerComponent * ncols; - - CGColorSpaceRef colorspace = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); - if (!colorspace) { - _buffer_release(buffer, NULL, 0); - return 1; - } + CGImageRef contents = colorSpace ? CGImageCreate( + deviceWidth, deviceHeight, 8, 32, deviceWidth * 4, + colorSpace, bitmapInfo, provider, NULL, false, + kCGRenderingIntentDefault + ) : NULL; - CGDataProviderRef provider = CGDataProviderCreateWithData(buffer, - buffer->buf, - buffer->len, - _buffer_release); - if (!provider) { - _buffer_release(buffer, NULL, 0); - CGColorSpaceRelease(colorspace); - return 1; + if (contents) { + [[self layer] setContents:(__bridge id)contents]; } - CGBitmapInfo bitmapInfo = kCGBitmapByteOrderDefault | kCGImageAlphaLast; - CGImageRef bitmap = CGImageCreate(ncols, - nrows, - bitsPerComponent, - bitsPerPixel, - bytesPerRow, - colorspace, - bitmapInfo, - provider, - NULL, - false, - kCGRenderingIntentDefault); - CGColorSpaceRelease(colorspace); + CGColorSpaceRelease(colorSpace); CGDataProviderRelease(provider); + CGImageRelease(contents); +} - if (!bitmap) { - return 1; - } - CGFloat deviceScale = _get_device_scale(cr); - CGContextSaveGState(cr); - CGContextDrawImage(cr, CGRectMake(0, 0, ncols/deviceScale, nrows/deviceScale), bitmap); - CGImageRelease(bitmap); - CGContextRestoreGState(cr); +- (void) updateCursorType:(int)cursorType +{ + // Keep track of whether this view has the hand cursor active rather + // than comparing against the process-level [NSCursor currentCursor] + _isHandCursorActive = (cursorType == 4); + + if (_isHandCursorActive) { + [self _updateHandCursor]; - return 0; + } else { + NSCursor *cursor = [@{ + @(1): [NSCursor arrowCursor], + @(2): [NSCursor pointingHandCursor], + @(3): [NSCursor crosshairCursor], + @(6): [NSCursor resizeLeftRightCursor], + @(7): [NSCursor resizeUpDownCursor], + } objectForKey:@(cursorType)]; + + [cursor set]; + } } -// Becomes -updateRubberbandWithDeviceX0:y0:x1:y1: -- (void) setRubberband:(NSRect)rect + +- (void) updateRubberbandWithDeviceX0:(int)x0 y0:(int)y0 x1:(int)x1 y1:(int)y1 { - // The space we want to redraw is a union of the previous rubberband - // with the new rubberband and then expanded (negative inset) by one - // in each direction to account for the stroke linewidth. - [self setNeedsDisplayInRect: NSInsetRect(NSUnionRect(rect, rubberband), -1, -1)]; - rubberband = rect; + CGRect rect = CGRectStandardize(CGRectMake(x0, y0, x1 - x0, y1 - y0)); + CGRect rubberbandFrame = [self convertRectFromBacking:rect]; + [self _updateRubberbandViewWithFrame:rubberbandFrame]; } + - (void) removeRubberband { - if (NSIsEmptyRect(rubberband)) { return; } - [self setNeedsDisplayInRect: rubberband]; - rubberband = NSZeroRect; + [self _updateRubberbandViewWithFrame:CGRectZero]; } +- (void) requestDisplayLayerWithNeedsDraw:(BOOL)needsDraw +{ + if ([NSThread isMainThread]) { + _needsDrawOnNextDisplayLayer = needsDraw; + [[self layer] setNeedsDisplay]; + + } else { + __weak id weakSelf = self; + + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf requestDisplayLayerWithNeedsDraw:needsDraw]; + }); + } +} + @end diff --git a/src/macos/MPLFigureManager.h b/src/macos/MPLFigureManager.h index 47d74b8bba59..0acc441a787a 100644 --- a/src/macos/MPLFigureManager.h +++ b/src/macos/MPLFigureManager.h @@ -1,7 +1,33 @@ #import #import +#import "MPLUtils.h" + +NS_ASSUME_NONNULL_BEGIN + +@class MPLFigureCanvas, MPLNavigationToolbar2; + +@interface MPLFigureManager : NSWindowController + +- (instancetype) initWithFigureCanvas:(MPLFigureCanvas *)figureCanvas NS_DESIGNATED_INITIALIZER; + +- (instancetype) initWithWindow:(nullable NSWindow *)window NS_UNAVAILABLE; +- (nullable instancetype) initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; + +- (void) show; +- (void) raise; +- (void) toggleFullScreen; +- (void) resizeToDeviceWidth:(int)width height:(int)height; +- (void) updateWindowAppearance:(nullable NSString *)windowAppearance; +- (void) updateWindowMode:(nullable NSString *)windowMode; +- (void) installToolbar:(MPLNavigationToolbar2 *)toolbar; + +@property (nonatomic, assign, nullable) PyObject *pyObject; + +@property (nonatomic) NSString *windowTitle; + +@property (nonatomic, readonly) MPLFigureCanvas *figureCanvas; +@property (nonatomic, nullable, readonly) MPLNavigationToolbar2 *toolbar; -@interface Window : NSWindow -- (NSRect)constrainFrameRect:(NSRect)rect toScreen:(NSScreen*)screen; -@property (nonatomic, assign) PyObject *pyObject; @end + +NS_ASSUME_NONNULL_END diff --git a/src/macos/MPLFigureManager.m b/src/macos/MPLFigureManager.m index 04fbd93f27ae..dbe4f77aa427 100644 --- a/src/macos/MPLFigureManager.m +++ b/src/macos/MPLFigureManager.m @@ -1,19 +1,214 @@ #import "MPLFigureManager.h" +#import "MPLFigureCanvas.h" +#import "MPLNavigationToolbar2.h" #import "MPLUtils.h" +@interface MPLFigureManager () +@end + + +@interface MPLUnconstrainedWindow : NSWindow +@end + -@implementation Window +@implementation MPLUnconstrainedWindow - (NSRect) constrainFrameRect:(NSRect)rect toScreen:(NSScreen *)screen { // Allow the window height to be larger than the screen height CGRect suggestedRect = [super constrainFrameRect:rect toScreen:screen]; - const CGFloat difference = rect.size.height - suggestedRect.size.height; - suggestedRect.origin.y -= difference; - suggestedRect.size.height += difference; + suggestedRect.origin.y -= (rect.size.height - suggestedRect.size.height); + suggestedRect.size.height = rect.size.height; return suggestedRect; } @end + + +@implementation MPLFigureManager + + +#pragma mark - Lifecycle + +- (instancetype) initWithFigureCanvas:(MPLFigureCanvas *)figureCanvas +{ + CGRect figureCanvasFrame = [figureCanvas frame]; + CGRect contentRect = figureCanvasFrame; + contentRect.origin = CGPointMake(100, 350); + + NSWindowStyleMask styleMask = NSWindowStyleMaskTitled + | NSWindowStyleMaskClosable + | NSWindowStyleMaskResizable + | NSWindowStyleMaskMiniaturizable; + + NSWindow *window = [[MPLUnconstrainedWindow alloc] initWithContentRect: contentRect + styleMask: styleMask + backing: NSBackingStoreBuffered + defer: YES]; + + [window setDelegate:self]; + [window makeFirstResponder:figureCanvas]; + [window setReleasedWhenClosed:NO]; + + // Match the window's color space to our Agg buffer. + // This prevents an in-process color space conversion when compositing and + // may allow for a GPU-accelerated conversion at the WindowServer level. + [window setColorSpace:[NSColorSpace sRGBColorSpace]]; + + // We want to handle the cursor changes from within MPL with set_cursor() ourselves + [window disableCursorRects]; + + [figureCanvas setAutoresizingMask:NSViewWidthSizable|NSViewHeightSizable]; + [[window contentView] addSubview:figureCanvas]; + + if ((self = [super initWithWindow:window])) { + [figureCanvas setManager:self]; + _figureCanvas = figureCanvas; + MPLLog("[Lifecycle] MPLFigureManager<%p> init", self); + } + + return self; +} + + +- (instancetype) initWithWindow:(nullable NSWindow *)window +{ + MPLUnavailable(); +} + + +- (nullable instancetype) initWithCoder:(NSCoder *)coder +{ + MPLUnavailable(); +} + + +- (void) dealloc +{ + MPLLog("[Lifecycle] MPLFigureManager<%p> dealloc", self); +} + + +#pragma mark - NSWindow Delegate Methods + +- (void) windowWillClose:(NSNotification *)notification +{ + MPLCallMethod(_pyObject, "_handle_window_will_close", ""); +} + + +- (BOOL) windowShouldClose:(NSNotification *)notification +{ + MPLCallMethod(_pyObject, "_handle_window_should_close", ""); + return YES; +} + + +#pragma mark - Public Methods + +- (void) show +{ + [[self window] makeKeyAndOrderFront:nil]; +} + + +- (void) raise +{ + [[self window] orderFrontRegardless]; +} + + +- (void) toggleFullScreen +{ + [[self window] toggleFullScreen:nil]; +} + + +- (void) resizeToDeviceWidth:(int)width height:(int)height +{ + NSWindow *window = [self window]; + CGRect rect = CGRectMake(0, 0, width, height); + [window convertRectFromBacking:rect]; + + if (_toolbar) { + rect.size.height += [_toolbar frame].size.height; + } + + [window setContentSize:rect.size]; +} + + +- (void) updateWindowAppearance:(nullable NSString *)windowAppearance +{ + NSAppearanceName name = windowAppearance ? [@{ + @"light": NSAppearanceNameAqua, + @"dark": NSAppearanceNameDarkAqua + } objectForKey:windowAppearance] : nil; + + [[self window] setAppearance:(name ? [NSAppearance appearanceNamed:name] : nil)]; +} + + +- (void) updateWindowMode:(nullable NSString *)windowMode +{ + NSNumber *tabbingModeValue = windowMode ? [@{ + @"system": @( NSWindowTabbingModeAutomatic ), + @"tab": @( NSWindowTabbingModePreferred ), + @"window": @( NSWindowTabbingModeDisallowed ) + } objectForKey:windowMode] : nil; + + [[self window] setTabbingMode:[tabbingModeValue integerValue]]; +} + + +- (void) installToolbar:(MPLNavigationToolbar2 *)toolbar +{ + if (_toolbar) return; + _toolbar = toolbar; + + NSWindow *window = [self window]; + NSView *canvas = [self figureCanvas]; + + CGRect windowFrame = [window frame]; + NSView *contentView = [window contentView]; + + CGRect bounds = [contentView bounds]; + CGRect canvasFrame = [canvas frame]; + CGRect toolbarFrame = [toolbar frame]; + + // Expand window downwards + windowFrame.origin.y -= toolbarFrame.size.height; + windowFrame.size.height += toolbarFrame.size.height; + [window setFrame:windowFrame display:NO]; + + // Move canvas upwards + canvasFrame.origin.y = toolbarFrame.size.height; + [canvas setFrame:canvasFrame]; + + // Adjust toolbar width and place at origin of window + toolbarFrame.origin = CGPointZero; + toolbarFrame.size.width = bounds.size.width; + [toolbar setAutoresizingMask:NSViewMaxYMargin|NSViewWidthSizable]; + [toolbar setFrame:toolbarFrame]; + + [contentView addSubview:toolbar]; +} + + +#pragma mark - Accessors + +- (void) setWindowTitle:(NSString *)title +{ + [[self window] setTitle:title]; +} + + +- (NSString *) windowTitle +{ + return [[self window] title]; +} + + +@end diff --git a/src/macos/MPLNavigationToolbar2.h b/src/macos/MPLNavigationToolbar2.h index 44d762530314..1a90b6a05684 100644 --- a/src/macos/MPLNavigationToolbar2.h +++ b/src/macos/MPLNavigationToolbar2.h @@ -1,16 +1,30 @@ #import #import -@interface MPLNavigationToolbar2 : NSObject -- (void)installCallbacks:(SEL[7])actions forButtons:(__strong NSButton*[7])buttons; -- (void)home:(id)sender; -- (void)back:(id)sender; -- (void)forward:(id)sender; -- (void)pan:(id)sender; -- (void)zoom:(id)sender; -- (void)configure_subplots:(id)sender; -- (void)save_figure:(id)sender; -@property (nonatomic, assign) PyObject *pyObject; -@property (nonatomic, readonly) NSButton *panButton; -@property (nonatomic, readonly) NSButton *zoomButton; +NS_ASSUME_NONNULL_BEGIN + +@interface MPLNavigationToolbar2 : NSView + +- (instancetype) init NS_DESIGNATED_INITIALIZER; + +- (instancetype) initWithFrame:(NSRect)frame NS_UNAVAILABLE; +- (nullable instancetype) initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; + +@property (nonatomic, assign, nullable) PyObject *pyObject; + +- (void) addItemWithTitle: (NSString *) title + tooltip: (NSString *) tooltip + imagePath: (NSString *) imagePath + callbackName: (NSString *) callbackName; + +- (void) addSeparator; + +- (void) updateSelectedItem:(NSString *)callback; +- (void) updateMessage:(NSString *)message; + +- (void) updateHistoryItemsWithBackEnabled: (BOOL) backEnabled + forwardEnabled: (BOOL) forwardEnabled; + @end + +NS_ASSUME_NONNULL_END diff --git a/src/macos/MPLNavigationToolbar2.m b/src/macos/MPLNavigationToolbar2.m index 8a3e89878916..fa335a62a910 100644 --- a/src/macos/MPLNavigationToolbar2.m +++ b/src/macos/MPLNavigationToolbar2.m @@ -1,37 +1,183 @@ #import "MPLNavigationToolbar2.h" #import "MPLUtils.h" +static const CGFloat sToolbarHeight = 36; +static const CGFloat sButtonHeight = 32; +static const CGFloat sButtonGap = 2; -@implementation MPLNavigationToolbar2 +@interface MPLNavigationToolbar2 () +@end + + +@implementation MPLNavigationToolbar2 { + NSMutableArray *_buttons; + NSMutableArray *_callbackNames; // Same count and indices as _buttons + + NSView *_buttonContainer; + NSTextField *_messageField; + CGFloat _nextButtonX; +} -- (void)installCallbacks:(SEL[7])actions forButtons:(__strong NSButton*[7])buttons + +#pragma mark - Lifecycle + +- (instancetype) init { - for (int i = 0; i < 7; i++) { - SEL action = actions[i]; - NSButton* button = buttons[i]; - [button setTarget: self]; - [button setAction: action]; - if (action == @selector(pan:)) { _panButton = button; } - if (action == @selector(zoom:)) { _zoomButton = button; } + if ((self = [super initWithFrame:CGRectMake(0, 0, 400, sToolbarHeight)])) { + _buttons = [NSMutableArray array]; + _callbackNames = [NSMutableArray array]; + + CGFloat buttonYOrigin = floor((sToolbarHeight - sButtonHeight) / 2.0); + CGRect containerFrame = CGRectMake(sButtonGap, buttonYOrigin, 200, sButtonHeight); + + _buttonContainer = [[NSView alloc] initWithFrame:containerFrame]; + [self addSubview:_buttonContainer]; + + [self _addMessageField]; + + MPLLog("[Lifecycle] MPLNavigationToolbar2<%p> init", self); } + + return self; +} + + +- (instancetype) initWithFrame:(NSRect)frame +{ + MPLUnavailable(); +} + + +- (nullable instancetype) initWithCoder:(NSCoder *)coder +{ + MPLUnavailable(); } --(void)home:(id)sender { gil_call_method(_pyObject, "home"); } --(void)back:(id)sender { gil_call_method(_pyObject, "back"); } --(void)forward:(id)sender { gil_call_method(_pyObject, "forward"); } --(void)pan:(id)sender +- (void) dealloc { - if ([sender state]) { [_zoomButton setState:NO]; } - gil_call_method(_pyObject, "pan"); + MPLLog("[Lifecycle] MPLNavigationToolbar2<%p> dealloc", self); } --(void)zoom:(id)sender + +#pragma mark - Private Methods + +- (void) _addMessageField +{ + NSFont *font = [NSFont monospacedDigitSystemFontOfSize:13.0 weight:NSFontWeightRegular]; + + NSTextField *messageField = [[NSTextField alloc] initWithFrame:CGRectZero]; + + [messageField setTranslatesAutoresizingMaskIntoConstraints:NO]; + [messageField setAlignment:NSTextAlignmentRight]; + [messageField setFont:font]; + [messageField setDrawsBackground:NO]; + [messageField setBordered:NO]; + [messageField setBezeled:NO]; + [messageField setSelectable:NO]; + [messageField setEditable:NO]; + [messageField setLineBreakMode:NSLineBreakByTruncatingHead]; + + [messageField setContentCompressionResistancePriority: NSLayoutPriorityDefaultLow + forOrientation: NSLayoutConstraintOrientationHorizontal]; + + [self addSubview:messageField]; + + [NSLayoutConstraint activateConstraints:@[ + [[messageField leftAnchor] constraintEqualToAnchor:[_buttonContainer rightAnchor]], + [[messageField rightAnchor] constraintEqualToAnchor:[self rightAnchor] constant:-sButtonGap], + [[messageField centerYAnchor] constraintEqualToAnchor:[self centerYAnchor]] + ]]; + + _messageField = messageField; +} + + +- (nullable NSButton *) _buttonWithCallbackName:(NSString *)callbackName +{ + NSUInteger index = [_callbackNames indexOfObject:callbackName]; + + return (index != NSNotFound) ? [_buttons objectAtIndex:index] : nil; +} + + +- (void) _callMethodForButton:(id)sender +{ + NSUInteger index = [_buttons indexOfObject:sender]; + + if (index != NSNotFound) { + NSString *callbackName = [_callbackNames objectAtIndex:index]; + MPLCallMethod(_pyObject, [callbackName UTF8String], ""); + } +} + + +#pragma mark - Public Methods + +- (void) addItemWithTitle: (NSString *) title + tooltip: (NSString *) tooltip + imagePath: (NSString *) imagePath + callbackName: (NSString *) callbackName +{ + NSImage *image = [[NSImage alloc] initWithContentsOfFile:imagePath]; + [image setSize:CGSizeMake(24, 24)]; + [image setTemplate:YES]; + + CGRect frame = CGRectMake(_nextButtonX, 0, sButtonHeight, sButtonHeight); + + NSButton *button = [[NSButton alloc] initWithFrame:frame]; + [button setBezelStyle:NSBezelStyleSmallSquare]; + [button setButtonType:NSButtonTypeMomentaryLight]; + [button setImage:image]; + [button setImagePosition:NSImageOnly]; + [button setImageScaling:NSImageScaleProportionallyDown]; + [button setAutoresizingMask:NSViewMaxXMargin | NSViewMinYMargin| NSViewMaxYMargin]; + [button setAccessibilityLabel:title]; + [button setToolTip:tooltip]; + [button setTarget:self]; + [button setAction:@selector(_callMethodForButton:)]; + + [_buttons addObject:button]; + [_callbackNames addObject:callbackName]; + + _nextButtonX = CGRectGetMaxX(frame) + sButtonGap; + + CGRect containerFrame = [_buttonContainer frame]; + containerFrame.size.width = _nextButtonX; + [_buttonContainer addSubview:button]; + [_buttonContainer setFrame:containerFrame]; +} + + +- (void) addSeparator +{ + // For now, do nothing to match existing implementation +} + + +- (void) updateSelectedItem:(NSString *)callbackName +{ + for (NSButton *button in _buttons) { + [button setState:NSControlStateValueOff]; + } + + NSButton *button = [self _buttonWithCallbackName:callbackName]; + [button setButtonType:NSButtonTypePushOnPushOff]; + [button setState:NSControlStateValueOn]; +} + + +- (void) updateMessage:(NSString *)message +{ + [_messageField setStringValue:message]; +} + + +- (void) updateHistoryItemsWithBackEnabled: (BOOL) backEnabled + forwardEnabled: (BOOL) forwardEnabled { - if ([sender state]) { [_panButton setState:NO]; } - gil_call_method(_pyObject, "zoom"); + [[self _buttonWithCallbackName:@"back"] setEnabled:backEnabled]; + [[self _buttonWithCallbackName:@"forward"] setEnabled:forwardEnabled]; } --(void)configure_subplots:(id)sender { gil_call_method(_pyObject, "configure_subplots"); } --(void)save_figure:(id)sender { gil_call_method(_pyObject, "save_figure"); } @end diff --git a/src/macos/MPLUtils.h b/src/macos/MPLUtils.h index 3b42fccceec6..337e22f543d9 100644 --- a/src/macos/MPLUtils.h +++ b/src/macos/MPLUtils.h @@ -1,6 +1,79 @@ #import +#import +#import #import +#import -void gil_call_method(PyObject* obj, const char* name); +NS_ASSUME_NONNULL_BEGIN -void process_event(char const* cls_name, char const* fmt, ...); +/* + When a method or function is NS_UNAVAILABLE, call MPLUnavailable() + in the implementation to throw a runtime error. +*/ +extern void _MPLUnavailable(const char *prettyFunction) __attribute__((__noreturn__)); + +#define MPLUnavailable() _MPLUnavailable(__PRETTY_FUNCTION__) + + +/* + Use the macOS unified logging system for debug logs. Logs are recorded + with almost no overhead unless a viewer is attached. +*/ +extern os_log_t MPLGetLogger(void); + +#define MPLLog(format, ...) os_log_debug(MPLGetLogger(), format, ##__VA_ARGS__) + + +/* + Acquire the GIL, call a method with the specified arguments, + discard the result, print any exception. +*/ +extern void MPLCallMethod( + PyObject * _Nullable pyObject, + const char *name, + char const * _Nullable format, ... +); + + +/* + Converts a Python str into an NSString. + Returns nil and raises a Python exception if the str could not be converted. +*/ +extern NSString * _Nullable MPLGetStringWithPyString(PyObject * _Nullable string); + + +/* + Converts a Python sequence of exactly one str object into an NSString. + Returns nil and raises a Python exception if the sequence is not exactly one + string or if the string could not be converted into an NSString. +*/ +extern NSString * _Nullable MPLGetStringWithPySequence(PyObject * _Nullable pySequence); + + +/* + Converts a Python sequence of str objects into an NSArray of NSString objects. + Returns nil and raises a Python exception if 'sequence' is not a sequence, + any item is not a string, or any item could not be converted into an NSString. +*/ +extern NSArray * _Nullable MPLGetStringArrayWithPySequence( + PyObject * _Nullable pySequence +); + + +/* + Calls getbuffer() on a Python object and returns the buffer as an NSData. + If expectedDimensions is non-0, verifies against ndim and fills outShape + Returns nil and raises a Python exception if any of the following occur: + 1) getbuffer() call fails + 2) buffer->buf is NULL + 3) buffer->len is <= 0 + 4) expectedDimensions is non-0 and not equal to buffer->ndim +*/ +extern NSData * _Nullable MPLGetBufferWithPyObject( + PyObject * _Nullable pyObject, + size_t expectedDimensions, + ssize_t * _Nullable outShape +); + + +NS_ASSUME_NONNULL_END diff --git a/src/macos/MPLUtils.m b/src/macos/MPLUtils.m index 3b172df454bf..f56096a40084 100644 --- a/src/macos/MPLUtils.m +++ b/src/macos/MPLUtils.m @@ -1,41 +1,225 @@ #import "MPLUtils.h" -// Acquire the GIL, call a method with no args, discarding the result and -// printing any exception. -void gil_call_method(PyObject* obj, const char* name) + +void _MPLUnavailable(const char *s) +{ + [NSException raise: NSInvalidArgumentException + format: @"'%s' called but marked with __attribute__((unavailable))", s]; + + __builtin_unreachable(); +} + + +os_log_t MPLGetLogger(void) { - PyGILState_STATE gstate = PyGILState_Ensure(); - PyObject* result = PyObject_CallMethod(obj, name, NULL); + static os_log_t sLogger = nil; + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sLogger = os_log_create("org.matplotlib", "org.matplotlib"); + }); + + return sLogger; +} + + +#pragma mark - Python Utility Functions + +void MPLCallMethod(PyObject *pyObject, const char *name, char const *format, ...) +{ + // It is possible for Obj-C objects to momentarily outlive their paired Python + // counterparts, especially when dealing with AppKit objects. Hence, allow + // messaging a NULL pyObject to be a no-op. + if (!pyObject) return; + + PyGILState_STATE gilState = PyGILState_Ensure(); + + PyObject *result = NULL; + + // Null or empty string, simply use PyObject_CallMethod() + if (!format || !format[0]) { + result = PyObject_CallMethod(pyObject, name, NULL); + + } else { + va_list va; + va_start(va, format); + + PyObject *args = Py_VaBuildValue(format, va); + PyObject *method = PyObject_GetAttrString(pyObject, name); + + // "Py_BuildValue() does not always build a tuple." + if (args && !PyTuple_Check(args)) { + PyObject *tuple = PyTuple_Pack(1, args); + Py_DECREF(args); + args = tuple; + } + + if (method && args) { + result = PyObject_Call(method, args, NULL); + } + + Py_XDECREF(method); + Py_XDECREF(args); + + va_end(va); + } + if (result) { Py_DECREF(result); } else { PyErr_Print(); } - PyGILState_Release(gstate); + + PyGILState_Release(gilState); } -void process_event(char const* cls_name, char const* fmt, ...) + +NSString *MPLGetStringWithPyString(PyObject *pyString) { - PyGILState_STATE gstate = PyGILState_Ensure(); - PyObject* module = NULL, * cls = NULL, - * args = NULL, * kwargs = NULL, - * event = NULL, * result = NULL; - va_list argp; - va_start(argp, fmt); - if (!(module = PyImport_ImportModule("matplotlib.backend_bases")) - || !(cls = PyObject_GetAttrString(module, cls_name)) - || !(args = PyTuple_New(0)) - || !(kwargs = Py_VaBuildValue(fmt, argp)) - || !(event = PyObject_Call(cls, args, kwargs)) - || !(result = PyObject_CallMethod(event, "_process", ""))) { - PyErr_Print(); + if (!pyString) { + if (!PyErr_Occurred()) PyErr_SetString(PyExc_RuntimeError, "Input is NULL"); + return nil; + } + + if (!PyUnicode_Check(pyString)) { + PyErr_SetString(PyExc_RuntimeError, "Input is not a string"); + return nil; + } + + const char *cString = PyUnicode_AsUTF8(pyString); + if (!cString) { + // PyUnicode_AsUTF8() should set error in this case + return nil; + } + + NSString *result = [NSString stringWithUTF8String:cString]; + if (!result) { + PyErr_SetString(PyExc_RuntimeError, "Could not create NSString"); + } + + return result; +} + + +NSArray *MPLGetStringArrayWithPySequence(PyObject *pySequence) +{ + if (!pySequence) { + if (!PyErr_Occurred()) PyErr_SetString(PyExc_RuntimeError, "Input is NULL"); + return nil; + } + + if (!PySequence_Check(pySequence)) { + PyErr_SetString(PyExc_RuntimeError, "Input is not a sequence"); + return nil; } - va_end(argp); - Py_XDECREF(module); - Py_XDECREF(cls); - Py_XDECREF(args); - Py_XDECREF(kwargs); - Py_XDECREF(event); - Py_XDECREF(result); - PyGILState_Release(gstate); + + Py_ssize_t size = PySequence_Size(pySequence); + if (size < 0) { + // PySequence_Size() should set error in this case + return nil; + } + + NSMutableArray *result = [NSMutableArray arrayWithCapacity:(NSUInteger)size]; + + for (Py_ssize_t i = 0; i < size; i++) { + PyObject *pyItem = PySequence_GetItem(pySequence, i); // New reference + NSString *string = MPLGetStringWithPyString(pyItem); + Py_DECREF(pyItem); + + if (string) { + [result addObject:string]; + } else { + return nil; + } + } + + return [result copy]; +} + + +NSString *MPLGetStringWithPySequence(PyObject *pySequence) +{ + NSArray *array = MPLGetStringArrayWithPySequence(pySequence); + + if (array && ([array count] != 1)) { + PyErr_SetString(PyExc_RuntimeError, "Input is not a sequence of exactly one string"); + return nil; + } + + return [array lastObject]; +} + + +NSDictionary *MPLGetStringDictionaryWithPyDict(PyObject *dict) +{ + if (!dict) { + if (!PyErr_Occurred()) PyErr_SetString(PyExc_RuntimeError, "Input is NULL"); + return nil; + } + + if (!PyDict_Check(dict)) { + PyErr_SetString(PyExc_RuntimeError, "Input is not a dict"); + return nil; + } + + NSMutableDictionary *result = [NSMutableDictionary dictionary]; + PyObject *pyKey = NULL; + PyObject *pyValue = NULL; + Py_ssize_t position = 0; + + while (PyDict_Next(dict, &position, &pyKey, &pyValue)) { + NSString *key = MPLGetStringWithPyString(pyKey); + NSString *value = MPLGetStringWithPyString(pyValue); + if (!key || !value) return nil; + + [result setObject:value forKey:key]; + } + + return [result copy]; +} + + +NSData * _Nullable MPLGetBufferWithPyObject( + PyObject * _Nullable pyObject, + size_t expectedDimensions, + ssize_t * _Nullable outShape +) { + if (!pyObject) { + if (!PyErr_Occurred()) PyErr_SetString(PyExc_RuntimeError, "Input is NULL"); + return nil; + } + + Py_buffer *buffer = malloc(sizeof(Py_buffer)); + + if (PyObject_GetBuffer(pyObject, buffer, PyBUF_CONTIG_RO) == -1) { + free(buffer); + return nil; + } + + void (^deallocator)(void *, NSUInteger) = ^(void *unused1, NSUInteger unused2) { + PyGILState_STATE gstate = PyGILState_Ensure(); + PyBuffer_Release((Py_buffer *)buffer); + free(buffer); + PyGILState_Release(gstate); + }; + + if (!buffer->buf || buffer->len <= 0) { + PyErr_SetString(PyExc_RuntimeError, "Buffer is invalid"); + deallocator(NULL, 0); + return nil; + } + + if (expectedDimensions && (expectedDimensions != buffer->ndim)) { + PyErr_SetString(PyExc_RuntimeError, "Unexpected buffer dimensions"); + deallocator(NULL, 0); + return nil; + } + + if (expectedDimensions && outShape) { + memcpy(outShape, buffer->shape, sizeof(ssize_t) * expectedDimensions); + } + + return [[NSData alloc] initWithBytesNoCopy: buffer->buf + length: buffer->len + deallocator: deallocator]; } diff --git a/src/macos/_macos.m b/src/macos/_macos.m index 6df636eed0db..fac26032118b 100755 --- a/src/macos/_macos.m +++ b/src/macos/_macos.m @@ -47,7 +47,7 @@ /* Variables to keep track of state and window count for show() */ static BOOL IsRunningFromShow = NO; -static NSHashTable *FigureWindowHashTable = nil; +static NSHashTable *FigureManagerHashTable = nil; // Global variable to store the original SIGINT handler static PyOS_sighandler_t originalSigintAction = NULL; @@ -57,8 +57,10 @@ static void errSetException(NSException *exception) { PyErr_SetString(PyExc_RuntimeError, [[exception reason] UTF8String]); } -// Stop the current app's run loop, sending an event to ensure it actually stops -static void stopWithEvent(void) { + +// Old implementation, goes away with MPLEventLoop PR +static void stopWithEvent(void) +{ [NSApp stop: nil]; // Post an event to trigger the actual stopping. // +[NSEvent otherEventWithType:...] is declared nullable but will not return @@ -77,14 +79,14 @@ static void stopWithEvent(void) { } } -// Signal handler for SIGINT, only argument matching for stopWithEvent -static void handleSigint(int signal) { + +// Old implementation, goes away with MPLEventLoop PR +static void handleSigint(int signal) +{ stopWithEvent(); } -// Helper function to flush all events. -// This is needed in some instances to ensure e.g. that windows are properly closed. -// It is used in the input hook as well as wrapped in a version callable from Python. +// Old implementation, goes away with MPLEventLoop PR static void flushEvents(void) { while (true) { @autoreleasepool { @@ -100,12 +102,13 @@ static void flushEvents(void) { } } +// Old implementation, goes away with MPLEventLoop PR static int wait_for_stdin(void) { BEGIN_OBJC_ENTRY // Short circuit if no windows are active // Rely on Python's input handling to manage CPU usage - // This queries the NSApp, rather than using our FigureWindowHashTable because that is modified when events still + // This queries the NSApp, rather than using our FigureWindowCount because that is decremented when events still // need to be processed to properly close the windows. @autoreleasepool { if (![[NSApp windows] count]) { @@ -148,72 +151,6 @@ static int wait_for_stdin(void) { } -/* ---------------------------- Python classes ---------------------------- */ - - -static bool backend_inited = false; - -static void lazy_init(void) { - if (backend_inited) { return; } - backend_inited = true; - - NSApp = [NSApplication sharedApplication]; - [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; - appDelegate = [[MPLAppDelegate alloc] init]; - [NSApp setDelegate:appDelegate]; - - // Run our own event loop while waiting for stdin on the Python side - // this is needed to keep the application responsive while waiting for input - PyOS_InputHook = wait_for_stdin; -} - -static PyObject * -event_loop_is_running(PyObject *self) -{ - BEGIN_OBJC_ENTRY - - if (backend_inited) { - Py_RETURN_TRUE; - } else { - Py_RETURN_FALSE; - } - - END_OBJC_ENTRY - RETURN_NULL_OR_NONE -} - -static PyObject * -wake_on_fd_write(PyObject *unused, PyObject *args) -{ - BEGIN_OBJC_ENTRY - int fd; - if (!PyArg_ParseTuple(args, "i", &fd)) { return NULL; } - NSFileHandle* fh = [[NSFileHandle alloc] initWithFileDescriptor: fd]; - __block id notificationID = [[NSNotificationCenter defaultCenter] - addObserverForName: NSFileHandleDataAvailableNotification - object: fh - queue: nil - usingBlock: ^(NSNotification* note) { - NSFileHandle *strongFileHandle __attribute__((unused)) = fh; - PyGILState_STATE gstate = PyGILState_Ensure(); - PyErr_CheckSignals(); - PyGILState_Release(gstate); - [[NSNotificationCenter defaultCenter] removeObserver:notificationID]; - }]; - [fh waitForDataInBackgroundAndNotify]; - END_OBJC_ENTRY - RETURN_NULL_OR_NONE -} - -static PyObject * -stop(PyObject *self, PyObject *unused) -{ - BEGIN_OBJC_ENTRY - stopWithEvent(); - END_OBJC_ENTRY - RETURN_NULL_OR_NONE -} - #pragma mark - FigureCanvas Type @@ -227,57 +164,23 @@ static void lazy_init(void) { static PyObject * FigureCanvas_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { - BEGIN_OBJC_ENTRY - - lazy_init(); return (PyObject *)((FigureCanvas *)type->tp_alloc(type, 0)); - - END_OBJC_ENTRY - return NULL; } static int FigureCanvas_init(FigureCanvas *self, PyObject *args, PyObject *kwds) { BEGIN_OBJC_ENTRY - MPLFigureCanvas *wrappedObject; - NSTrackingArea *trackingArea; - PyObject *builtins = NULL, - *super_obj = NULL, - *super_init = NULL, - *init_res = NULL, - *wh = NULL; - // super(FigureCanvasMac, self).__init__(*args, **kwargs) - if (!(builtins = PyImport_AddModule("builtins")) // borrowed. - || !(super_obj = PyObject_CallMethod(builtins, "super", "OO", &FigureCanvasType, self)) - || !(super_init = PyObject_GetAttrString(super_obj, "__init__")) - || !(init_res = PyObject_Call(super_init, args, kwds))) { - goto exit; - } + int width, height; - if (!(wh = PyObject_CallMethod((PyObject *)self, "get_width_height", "")) - || !PyArg_ParseTuple(wh, "ii", &width, &height)) { - goto exit; + if (!PyArg_ParseTuple(args, "ii", &width, &height)) { + return -1; } + NSRect rect = NSMakeRect(0.0, 0.0, width, height); - wrappedObject = [[MPLFigureCanvas alloc] initWithFrame: rect]; - wrappedObject.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; - int opts = (NSTrackingMouseEnteredAndExited | NSTrackingMouseMoved | - NSTrackingActiveInKeyWindow | NSTrackingInVisibleRect); - trackingArea = [[NSTrackingArea alloc] initWithRect: rect - options: opts - owner: wrappedObject - userInfo: nil]; - [wrappedObject addTrackingArea:trackingArea]; - self->object = wrappedObject; + self->object = [[MPLFigureCanvas alloc] initWithFrame: rect]; [self->object setPyObject:(PyObject *)self]; -exit: - Py_XDECREF(super_obj); - Py_XDECREF(super_init); - Py_XDECREF(init_res); - Py_XDECREF(wh); - END_OBJC_ENTRY return PyErr_Occurred() ? -1 : 0; } @@ -300,10 +203,25 @@ static void lazy_init(void) { } static PyObject * -FigureCanvas_update(FigureCanvas *self) +FigureCanvas_update_layer_contents(FigureCanvas *self, PyObject *args) { BEGIN_OBJC_ENTRY - [self->object setNeedsDisplay: YES]; + PyObject *bufferPyObject; + if (!PyArg_ParseTuple(args, "O", &bufferPyObject)) { return NULL; } + + ssize_t shape[3]; + NSData *buffer = MPLGetBufferWithPyObject(bufferPyObject, 3, shape); + if (!buffer) { return NULL; } + + if (shape[0] <= 0 || shape[1] <= 0 || shape[2] != 4) { + PyErr_SetString(PyExc_RuntimeError, "Unexpected buffer shape"); + return NULL; + } + + [self->object updateLayerContentsWithBuffer: buffer + deviceWidth: (size_t)shape[1] + deviceHeight: (size_t)shape[0]]; + END_OBJC_ENTRY RETURN_NULL_OR_NONE; } @@ -321,16 +239,20 @@ static void lazy_init(void) { Py_END_ALLOW_THREADS - [self->object displayIfNeeded]; + [self->object requestDisplayLayerWithNeedsDraw:NO]; END_OBJC_ENTRY RETURN_NULL_OR_NONE } -static PyObject * __attribute__((unused)) -FigureCanvas_request_idle_draw(FigureCanvas *self) +static PyObject * +FigureCanvas_request_display_layer(FigureCanvas *self, PyObject *args) { BEGIN_OBJC_ENTRY - // Will be implemented + + int needsDraw; + if (!PyArg_ParseTuple(args, "p", &needsDraw)) { return NULL; } + + [self->object requestDisplayLayerWithNeedsDraw:(needsDraw > 0)]; END_OBJC_ENTRY RETURN_NULL_OR_NONE } @@ -339,25 +261,14 @@ static void lazy_init(void) { FigureCanvas_set_cursor(FigureCanvas *self, PyObject *args) { BEGIN_OBJC_ENTRY - int i; - if (!PyArg_ParseTuple(args, "i", &i)) { return NULL; } - switch (i) { - case 1: [[NSCursor arrowCursor] set]; break; - case 2: [[NSCursor pointingHandCursor] set]; break; - case 3: [[NSCursor crosshairCursor] set]; break; - case 4: - if (mpl_leftMouseGrabbing) { - [[NSCursor closedHandCursor] set]; - } else { - [[NSCursor openHandCursor] set]; - } - break; - /* macOS handles busy state itself so no need to set a cursor here */ - case 5: break; - case 6: [[NSCursor resizeLeftRightCursor] set]; break; - case 7: [[NSCursor resizeUpDownCursor] set]; break; - default: return NULL; + + int cursorType; + if (!PyArg_ParseTuple(args, "i", &cursorType)) { + return NULL; } + + [self->object updateCursorType:cursorType]; + END_OBJC_ENTRY RETURN_NULL_OR_NONE } @@ -366,22 +277,14 @@ static void lazy_init(void) { FigureCanvas_set_rubberband(FigureCanvas *self, PyObject *args) { BEGIN_OBJC_ENTRY - MPLFigureCanvas *figureCanvas = self->object; - if (!figureCanvas) { - PyErr_SetString(PyExc_RuntimeError, "MPLFigureCanvas* is NULL"); - return NULL; - } + int x0, y0, x1, y1; if (!PyArg_ParseTuple(args, "iiii", &x0, &y0, &x1, &y1)) { return NULL; } - x0 /= figureCanvas->device_scale; - x1 /= figureCanvas->device_scale; - y0 /= figureCanvas->device_scale; - y1 /= figureCanvas->device_scale; - NSRect rubberband = NSMakeRect(x0 < x1 ? x0 : x1, y0 < y1 ? y0 : y1, - abs(x1 - x0), abs(y1 - y0)); - [figureCanvas setRubberband: rubberband]; + + [self->object updateRubberbandWithDeviceX0:x0 y0:y0 x1:x1 y1:y1]; + END_OBJC_ENTRY RETURN_NULL_OR_NONE } @@ -463,14 +366,18 @@ static void lazy_init(void) { .tp_repr = (reprfunc)FigureCanvas_repr, .tp_methods = (PyMethodDef[]){ - {"update", - (PyCFunction)FigureCanvas_update, - METH_NOARGS, + {"_update_layer_contents", + (PyCFunction)FigureCanvas_update_layer_contents, + METH_VARARGS, NULL}, // docstring inherited {"flush_events", (PyCFunction)FigureCanvas_flush_events, METH_NOARGS, NULL}, // docstring inherited + {"_request_display_layer", + (PyCFunction)FigureCanvas_request_display_layer, + METH_VARARGS, + NULL}, // docstring inherited {"set_cursor", (PyCFunction)FigureCanvas_set_cursor, METH_VARARGS, @@ -502,7 +409,7 @@ static void lazy_init(void) { typedef struct { PyObject_HEAD - __strong Window *object; + __strong MPLFigureManager *object; } FigureManager; static PyObject * @@ -519,7 +426,6 @@ static void lazy_init(void) { return NULL; } - lazy_init(); return (PyObject *)((FigureManager *)type->tp_alloc(type, 0)); END_OBJC_ENTRY @@ -530,57 +436,30 @@ static void lazy_init(void) { FigureManager_init(FigureManager *self, PyObject *args, PyObject *kwds) { BEGIN_OBJC_ENTRY - PyObject *canvas; - if (!PyArg_ParseTuple(args, "O", &canvas)) { + PyObject *figureCanvasPyObject; + if (!PyArg_ParseTuple(args, "O", &figureCanvasPyObject)) { return -1; } - MPLFigureCanvas *figureCanvas = ((FigureCanvas*)canvas)->object; - if (!figureCanvas) { /* Something really weird going on */ - PyErr_SetString(PyExc_RuntimeError, "MPLFigureCanvas* is NULL"); - return -1; - } + MPLFigureCanvas *figureCanvas = ((FigureCanvas *)figureCanvasPyObject)->object; - PyObject *size = PyObject_CallMethod(canvas, "get_width_height", ""); - int width, height; - if (!size || !PyArg_ParseTuple(size, "ii", &width, &height)) { - Py_XDECREF(size); - return -1; - } - Py_DECREF(size); - - NSRect rect = NSMakeRect( /* x */ 100, /* y */ 350, width, height); - - Window* window = [[Window alloc] initWithContentRect: rect - styleMask: NSWindowStyleMaskTitled - | NSWindowStyleMaskClosable - | NSWindowStyleMaskResizable - | NSWindowStyleMaskMiniaturizable - backing: NSBackingStoreBuffered - defer: YES]; - [window setDelegate: figureCanvas]; - [window makeFirstResponder: figureCanvas]; - [window setReleasedWhenClosed:NO]; - [[window contentView] addSubview: figureCanvas]; - [figureCanvas updateDevicePixelRatio: [window backingScaleFactor]]; - - self->object = window; + self->object = [[MPLFigureManager alloc] initWithFigureCanvas:figureCanvas]; [self->object setPyObject:(PyObject *)self]; - if (!FigureWindowHashTable) { - FigureWindowHashTable = [NSHashTable weakObjectsHashTable]; + if (!FigureManagerHashTable) { + FigureManagerHashTable = [NSHashTable weakObjectsHashTable]; } - [FigureWindowHashTable addObject:window]; + [FigureManagerHashTable addObject:self->object]; END_OBJC_ENTRY return 0; } -static PyObject * __attribute__((unused)) +static PyObject * FigureManager__set_window_appearance(FigureManager *self, PyObject *args) { BEGIN_OBJC_ENTRY - // Will be implemented + [self->object updateWindowAppearance:MPLGetStringWithPySequence(args)]; END_OBJC_ENTRY RETURN_NULL_OR_NONE } @@ -589,19 +468,7 @@ static void lazy_init(void) { FigureManager__set_window_mode(FigureManager *self, PyObject *args) { BEGIN_OBJC_ENTRY - const char *window_mode; - if (!PyArg_ParseTuple(args, "s", &window_mode) || !self->object) { - return NULL; - } - - NSString* window_mode_str = [NSString stringWithUTF8String: window_mode]; - if ([window_mode_str isEqualToString: @"tab"]) { - [self->object setTabbingMode: NSWindowTabbingModePreferred]; - } else if ([window_mode_str isEqualToString: @"window"]) { - [self->object setTabbingMode: NSWindowTabbingModeDisallowed]; - } else { // system settings - [self->object setTabbingMode: NSWindowTabbingModeAutomatic]; - } + [self->object updateWindowMode:MPLGetStringWithPySequence(args)]; END_OBJC_ENTRY RETURN_NULL_OR_NONE } @@ -609,21 +476,21 @@ static void lazy_init(void) { static PyObject * FigureManager_repr(FigureManager *self) { - return PyUnicode_FromFormat("FigureManager<%p> wrapping Window<%p>", + return PyUnicode_FromFormat("FigureManager<%p> wrapping MPLFigureManager<%p>", (void *)self, (__bridge void *)self->object); } static void -FigureManager__closeAndClearWindow(FigureManager *self) +FigureManager__close_and_clear_window_impl(FigureManager *self) { if (self->object) { + [FigureManagerHashTable removeObject:self->object]; + [self->object close]; - [self->object setDelegate:nil]; [self->object setPyObject:NULL]; - [FigureWindowHashTable removeObject:self->object]; self->object = nil; - if ([FigureWindowHashTable count] == 0 && IsRunningFromShow) { + if ([FigureManagerHashTable count] == 0 && IsRunningFromShow) { [NSApp stop:nil]; } } @@ -633,7 +500,7 @@ static void lazy_init(void) { FigureManager_dealloc(FigureManager *self) { BEGIN_OBJC_ENTRY - FigureManager__closeAndClearWindow(self); + FigureManager__close_and_clear_window_impl(self); END_OBJC_ENTRY Py_TYPE(self)->tp_free((PyObject *)self); } @@ -642,7 +509,7 @@ static void lazy_init(void) { FigureManager__show(FigureManager *self) { BEGIN_OBJC_ENTRY - [self->object makeKeyAndOrderFront: nil]; + [self->object show]; END_OBJC_ENTRY RETURN_NULL_OR_NONE } @@ -651,88 +518,26 @@ static void lazy_init(void) { FigureManager__raise(FigureManager *self) { BEGIN_OBJC_ENTRY - [self->object orderFrontRegardless]; + [self->object raise]; END_OBJC_ENTRY RETURN_NULL_OR_NONE } static PyObject * -FigureManager_destroy(FigureManager *self) +FigureManager__close_and_clear_window(FigureManager *self) { BEGIN_OBJC_ENTRY - FigureManager__closeAndClearWindow(self); - - // call super(self, FigureManager).destroy() - it seems we need the - // explicit arguments, and just super() doesn't work in the C API. - PyObject *super_obj = PyObject_CallFunctionObjArgs( - (PyObject *)&PySuper_Type, - (PyObject *)&FigureManagerType, - self, - NULL - ); - if (super_obj == NULL) { - return NULL; // error - } - PyObject *result = PyObject_CallMethod(super_obj, "destroy", NULL); - Py_DECREF(super_obj); - if (result == NULL) { - return NULL; // error - } - Py_DECREF(result); - - END_OBJC_ENTRY - RETURN_NULL_OR_NONE -} - -static PyObject * -FigureManager_set_icon(PyObject *null, PyObject *args) { - BEGIN_OBJC_ENTRY - PyObject* icon_path; - if (!PyArg_ParseTuple(args, "O&", &PyUnicode_FSDecoder, &icon_path)) { - return NULL; - } - const char* icon_path_ptr = PyUnicode_AsUTF8(icon_path); - if (!icon_path_ptr) { - Py_DECREF(icon_path); - return NULL; - } - - NSString* ns_icon_path = [NSString stringWithUTF8String: icon_path_ptr]; - Py_DECREF(icon_path); - if (!ns_icon_path) { - PyErr_SetString(PyExc_RuntimeError, "Could not convert to NSString*"); - return NULL; - } - NSImage* image = [[NSImage alloc] initByReferencingFile: ns_icon_path]; - if (!image) { - PyErr_SetString(PyExc_RuntimeError, "Could not create NSImage*"); - return NULL; - } - if (!image.valid) { - PyErr_SetString(PyExc_RuntimeError, "Image is not valid"); - return NULL; - } - - NSApplication* app = [NSApplication sharedApplication]; - app.applicationIconImage = image; - + FigureManager__close_and_clear_window_impl(self); END_OBJC_ENTRY RETURN_NULL_OR_NONE } static PyObject * -FigureManager_set_window_title(FigureManager* self, +FigureManager_set_window_title(FigureManager *self, PyObject *args, PyObject *kwds) { BEGIN_OBJC_ENTRY - const char* title; - if (!PyArg_ParseTuple(args, "s", &title)) { - return NULL; - } - // PyArg_ParseTuple "s" guarantees valid UTF-8, so stringWithUTF8String: will - // not return nil here; the nullable annotation is a false positive. - // NOLINTNEXTLINE(clang-analyzer-nullability.NullablePassedToNonnull) - [self->object setTitle: [NSString stringWithUTF8String: title]]; + [self->object setWindowTitle:MPLGetStringWithPySequence(args)]; END_OBJC_ENTRY RETURN_NULL_OR_NONE } @@ -741,7 +546,7 @@ static void lazy_init(void) { FigureManager_get_window_title(FigureManager *self) { BEGIN_OBJC_ENTRY - NSString *title = [self->object title]; + NSString *title = [self->object windowTitle]; if (title) { return PyUnicode_FromString([title UTF8String]); } @@ -757,14 +562,7 @@ static void lazy_init(void) { if (!PyArg_ParseTuple(args, "ii", &width, &height)) { return NULL; } - Window* window = self->object; - if (window) { - CGFloat device_pixel_ratio = [window backingScaleFactor]; - width /= device_pixel_ratio; - height /= device_pixel_ratio; - // 36 comes from hard-coded size of toolbar later in code - [window setContentSize: NSMakeSize(width, height + 36.)]; - } + [self->object resizeToDeviceWidth:width height:height]; END_OBJC_ENTRY RETURN_NULL_OR_NONE } @@ -773,7 +571,7 @@ static void lazy_init(void) { FigureManager_full_screen_toggle(FigureManager *self) { BEGIN_OBJC_ENTRY - [self->object toggleFullScreen: nil]; + [self->object toggleFullScreen]; END_OBJC_ENTRY RETURN_NULL_OR_NONE } @@ -781,7 +579,8 @@ static void lazy_init(void) { static PyTypeObject FigureManagerType = { PyVarObject_HEAD_INIT(NULL, 0) .tp_name = "matplotlib.backends._macosx.FigureManager", - .tp_doc = PyDoc_STR("A FigureManager object wraps a Cocoa NSWindow object."), + .tp_doc = PyDoc_STR("A FigureManager object wraps a " + "MPLFigureManager Objective-C object."), .tp_basicsize = sizeof(FigureManager), .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, @@ -797,17 +596,17 @@ static void lazy_init(void) { {"_raise", (PyCFunction)FigureManager__raise, METH_NOARGS}, - {"destroy", - (PyCFunction)FigureManager_destroy, + {"_close_and_clear_window", + (PyCFunction)FigureManager__close_and_clear_window, METH_NOARGS}, + {"_set_window_appearance", + (PyCFunction)FigureManager__set_window_appearance, + METH_VARARGS, + PyDoc_STR("Set the window appearance (system, light, dark)")}, {"_set_window_mode", (PyCFunction)FigureManager__set_window_mode, METH_VARARGS, PyDoc_STR("Set the window open mode (system, tab, window)")}, - {"set_icon", - (PyCFunction)FigureManager_set_icon, - METH_STATIC | METH_VARARGS, - PyDoc_STR("Set application icon")}, {"set_window_title", (PyCFunction)FigureManager_set_window_title, METH_VARARGS}, @@ -829,136 +628,43 @@ static void lazy_init(void) { typedef struct { PyObject_HEAD - __strong NSTextView *messagebox; __strong MPLNavigationToolbar2 *object; - int height; } NavigationToolbar2; static PyObject * NavigationToolbar2_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { - BEGIN_OBJC_ENTRY - lazy_init(); - NavigationToolbar2 *self = (NavigationToolbar2 *)type->tp_alloc(type, 0); - return (PyObject *)self; - END_OBJC_ENTRY - return NULL; + return (PyObject *)((NavigationToolbar2*)type->tp_alloc(type, 0)); } static int NavigationToolbar2_init(NavigationToolbar2 *self, PyObject *args, PyObject *kwds) { BEGIN_OBJC_ENTRY + FigureCanvas *canvas; - const char* images[7]; - const char* tooltips[7]; - - const float gap = 2; - const int height = 36; - const int imagesize = 24; - - if (!PyArg_ParseTuple(args, "O!(sssssss)(sssssss)", - &FigureCanvasType, &canvas, - &images[0], &images[1], &images[2], &images[3], - &images[4], &images[5], &images[6], - &tooltips[0], &tooltips[1], &tooltips[2], &tooltips[3], - &tooltips[4], &tooltips[5], &tooltips[6])) { + + if (!PyArg_ParseTuple(args, "O!", &FigureCanvasType, &canvas)) { return -1; } MPLFigureCanvas *figureCanvas = canvas->object; if (!figureCanvas) { - PyErr_SetString(PyExc_RuntimeError, "MPLFigureCanvas* is NULL"); + PyErr_SetString(PyExc_RuntimeError, "MPLFigureCanvas is NULL"); return -1; } - self->height = height; - - NSRect bounds = [figureCanvas bounds]; - NSWindow* window = [figureCanvas window]; - - bounds.origin.y += height; - [figureCanvas setFrame: bounds]; - - bounds.size.height += height; - [window setContentSize: bounds.size]; - - NSButton* buttons[7]; - SEL actions[7] = {@selector(home:), - @selector(back:), - @selector(forward:), - @selector(pan:), - @selector(zoom:), - @selector(configure_subplots:), - @selector(save_figure:)}; - NSButtonType buttontypes[7] = {NSButtonTypeMomentaryLight, - NSButtonTypeMomentaryLight, - NSButtonTypeMomentaryLight, - NSButtonTypePushOnPushOff, - NSButtonTypePushOnPushOff, - NSButtonTypeMomentaryLight, - NSButtonTypeMomentaryLight}; - - NSRect rect; - NSSize size; - NSSize scale; - - rect = NSMakeRect(0, 0, imagesize, imagesize); - rect = [window convertRectToBacking: rect]; - size = rect.size; - scale = NSMakeSize(imagesize / size.width, imagesize / size.height); - - rect.size.width = 32; - rect.size.height = 32; - rect.origin.x = gap; - rect.origin.y = 0.5*(height - rect.size.height); - - for (int i = 0; i < 7; i++) { - // PyArg_ParseTuple "s" guarantees valid UTF-8; stringWithUTF8String: will not return nil. - NSString* filename = [NSString stringWithUTF8String: images[i]]; - NSString* tooltip = [NSString stringWithUTF8String: tooltips[i]]; - // NOLINTNEXTLINE(clang-analyzer-nullability.NullablePassedToNonnull) - NSImage* image = [[NSImage alloc] initWithContentsOfFile: filename]; - buttons[i] = [[NSButton alloc] initWithFrame: rect]; - [image setSize: size]; - // Specify that it is a template image so the content tint - // color gets updated with the system theme (dark/light) - [image setTemplate: YES]; - [buttons[i] setBezelStyle: NSBezelStyleShadowlessSquare]; - [buttons[i] setButtonType: buttontypes[i]]; - [buttons[i] setImage: image]; - [buttons[i] scaleUnitSquareToSize: scale]; - [buttons[i] setImagePosition: NSImageOnly]; - [buttons[i] setToolTip: tooltip]; - [[window contentView] addSubview: buttons[i]]; - rect.origin.x += rect.size.width + gap; + if ([[figureCanvas manager] toolbar]) { + PyErr_SetString(PyExc_RuntimeError, "MPLFigureManager already has a toolbar"); + return -1; } - MPLNavigationToolbar2 *wrappedObject; - wrappedObject = [[MPLNavigationToolbar2 alloc] init]; - [wrappedObject setPyObject:(PyObject*)self]; - [wrappedObject installCallbacks: actions forButtons: buttons]; - - NSFont* font = [NSFont systemFontOfSize: 0.0]; - // rect.origin.x is now at the far right edge of the buttons - // we want the messagebox to take up the rest of the toolbar area - // Make it a zero-width box if we don't have enough room - rect.size.width = fmax(bounds.size.width - rect.origin.x, 0); - rect.origin.x = bounds.size.width - rect.size.width; - NSTextView* messagebox = [[NSTextView alloc] initWithFrame: rect]; - messagebox.textContainer.maximumNumberOfLines = 2; - messagebox.textContainer.lineBreakMode = NSLineBreakByTruncatingTail; - messagebox.alignment = NSTextAlignmentRight; - [messagebox setFont: font]; - [messagebox setDrawsBackground: NO]; - [messagebox setSelectable: NO]; - /* if selectable, the messagebox can become first responder, - * which is not supposed to happen */ - [[window contentView] addSubview: messagebox]; - [[window contentView] display]; - - self->object = wrappedObject; - self->messagebox = messagebox; + MPLNavigationToolbar2 *toolbar = [[MPLNavigationToolbar2 alloc] init]; + [toolbar setPyObject:(PyObject *)self]; + self->object = toolbar; + + [[figureCanvas manager] installToolbar:toolbar]; + END_OBJC_ENTRY return 0; } @@ -969,53 +675,71 @@ static void lazy_init(void) { BEGIN_OBJC_ENTRY [self->object setPyObject:NULL]; self->object = nil; - self->messagebox = nil; END_OBJC_ENTRY - Py_TYPE(self)->tp_free((PyObject*)self); + Py_TYPE(self)->tp_free((PyObject *)self); } static PyObject * -NavigationToolbar2_repr(NavigationToolbar2* self) +NavigationToolbar2_repr(NavigationToolbar2 *self) { return PyUnicode_FromFormat("NavigationToolbar2<%p> wrapping MPLNavigationToolbar2<%p>", (void *)self, (__bridge void *)self->object); } static PyObject * -NavigationToolbar2_set_message(NavigationToolbar2 *self, PyObject *args) +NavigationToolbar2_add_item(NavigationToolbar2 *self, PyObject *args) +{ + BEGIN_OBJC_ENTRY + + NSArray *strings = MPLGetStringArrayWithPySequence(args); + if ([strings count] != 4) return NULL; + + [self->object addItemWithTitle: [strings objectAtIndex:0] + tooltip: [strings objectAtIndex:1] + imagePath: [strings objectAtIndex:2] + callbackName: [strings objectAtIndex:3]]; + + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +NavigationToolbar2_add_separator(NavigationToolbar2 *self, PyObject *args) { BEGIN_OBJC_ENTRY - const char *message; - - if (!PyArg_ParseTuple(args, "s", &message)) { return NULL; } - - NSTextView* messagebox = self->messagebox; - - if (messagebox) { - // PyArg_ParseTuple "s" guarantees valid UTF-8; stringWithUTF8String: will not return nil. - NSString* text = [NSString stringWithUTF8String: message]; - // NOLINTNEXTLINE(clang-analyzer-nullability.NullablePassedToNonnull) - [messagebox setString: text]; - - // Adjust width and height with the window size and content - NSRect rectWindow = [messagebox.superview frame]; - NSRect rect = [messagebox frame]; - // Entire region to the right of the buttons - rect.size.width = rectWindow.size.width - rect.origin.x; - [messagebox setFrame: rect]; - // We want to control the vertical position of - // the rect by the content size to center it vertically - [messagebox.layoutManager ensureLayoutForTextContainer: messagebox.textContainer]; - NSRect contentRect = [messagebox.layoutManager usedRectForTextContainer: messagebox.textContainer]; - rect.origin.y = 0.5 * (self->height - contentRect.size.height); - rect.size.height = contentRect.size.height; - [messagebox setFrame: rect]; - // Disable cursorRects so that the cursor doesn't get updated by events - // in NSApp (like resizing TextViews), we want to handle the cursor - // changes from within MPL with set_cursor() ourselves - [[messagebox.superview window] disableCursorRects]; + [self->object addSeparator]; + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +NavigationToolbar2_update_selected_item(NavigationToolbar2 *self, PyObject *args) +{ + BEGIN_OBJC_ENTRY + NSString *callbackName = MPLGetStringWithPySequence(args); + if (callbackName) [self->object updateSelectedItem:callbackName]; + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +NavigationToolbar2_update_history_items(NavigationToolbar2 *self, PyObject *args) +{ + BEGIN_OBJC_ENTRY + int backEnabled, forwardEnabled; + if (PyArg_ParseTuple(args, "ii", &backEnabled, &forwardEnabled)) { + [self->object updateHistoryItemsWithBackEnabled: (backEnabled > 0) + forwardEnabled: (forwardEnabled > 0)]; } + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} +static PyObject * +NavigationToolbar2_set_message(NavigationToolbar2 *self, PyObject *args) +{ + BEGIN_OBJC_ENTRY + [self->object updateMessage:MPLGetStringWithPySequence(args)]; END_OBJC_ENTRY RETURN_NULL_OR_NONE } @@ -1032,7 +756,23 @@ static void lazy_init(void) { .tp_dealloc = (destructor)NavigationToolbar2_dealloc, .tp_repr = (reprfunc)NavigationToolbar2_repr, - .tp_methods = (PyMethodDef[]){ // All docstrings are inherited. + .tp_methods = (PyMethodDef[]){ + {"add_item", + (PyCFunction)NavigationToolbar2_add_item, + METH_VARARGS, + PyDoc_STR("Adds an item to the toolbar.")}, + {"add_separator", + (PyCFunction)NavigationToolbar2_add_separator, + METH_NOARGS, + PyDoc_STR("Adds a separator to the toolbar.")}, + {"update_selected_item", + (PyCFunction)NavigationToolbar2_update_selected_item, + METH_VARARGS, + PyDoc_STR("Selects the item with the specified callback name.")}, + {"update_history_items", + (PyCFunction)NavigationToolbar2_update_history_items, + METH_VARARGS, + PyDoc_STR("Sets the enabled status of the back/forward items")}, {"set_message", (PyCFunction)NavigationToolbar2_set_message, METH_VARARGS}, @@ -1040,64 +780,6 @@ static void lazy_init(void) { }, }; -static PyObject * -choose_save_file(PyObject *unused, PyObject *args) -{ - BEGIN_OBJC_ENTRY - - int result; - const char* title; - const char* directory; - const char* default_filename; - if (!PyArg_ParseTuple(args, "sss", &title, &directory, &default_filename)) { - return NULL; - } - NSSavePanel* panel = [NSSavePanel savePanel]; - [panel setTitle: [NSString stringWithUTF8String: title]]; - // PyArg_ParseTuple "s" guarantees valid UTF-8; stringWithUTF8String: will not return nil. - // NOLINTNEXTLINE(clang-analyzer-nullability.NullablePassedToNonnull) - [panel setDirectoryURL: [NSURL fileURLWithPath: [NSString stringWithUTF8String: directory] - isDirectory: YES]]; - [panel setNameFieldStringValue: [NSString stringWithUTF8String: default_filename]]; - result = [panel runModal]; - if (result == NSModalResponseOK) { - NSString *filename = [[panel URL] path]; - if (!filename) { - PyErr_SetString(PyExc_RuntimeError, "Failed to obtain filename"); - return 0; - } - return PyUnicode_FromString([filename UTF8String]); - } - - END_OBJC_ENTRY - RETURN_NULL_OR_NONE -} - -static PyObject * -show(PyObject *self) -{ - BEGIN_OBJC_ENTRY - - // Iterating over -[NSApp windows] will add the windows to the topmost - // autorelease pool, wrap in @autoreleasepool as -[NSApp run] is long-running. - @autoreleasepool { - [NSApp activateIgnoringOtherApps: YES]; - - for (NSWindow *window in [FigureWindowHashTable allObjects]) { - [window orderFront:nil]; - } - } - - Py_BEGIN_ALLOW_THREADS - IsRunningFromShow = YES; - [NSApp run]; - IsRunningFromShow = NO; - Py_END_ALLOW_THREADS - - END_OBJC_ENTRY - RETURN_NULL_OR_NONE -} - #pragma mark - Timer Type @@ -1110,12 +792,7 @@ static void lazy_init(void) { static PyObject * Timer_new(PyTypeObject *type, PyObject *args, PyObject *kwds) { - BEGIN_OBJC_ENTRY - lazy_init(); - Timer *self = (Timer *)type->tp_alloc(type, 0); - return (PyObject *)self; - END_OBJC_ENTRY - return NULL; + return (PyObject *)((Timer*)type->tp_alloc(type, 0)); } static PyObject * @@ -1162,7 +839,7 @@ static void lazy_init(void) { timer = [NSTimer timerWithTimeInterval: interval repeats: !single block: ^(NSTimer *timer) { - gil_call_method((PyObject *)self, "_on_timer"); + MPLCallMethod((PyObject *)self, "_on_timer", ""); if (single) { // A single-shot timer will be automatically invalidated when it fires, so // we shouldn't do it ourselves when the object is deleted. @@ -1226,12 +903,204 @@ static void lazy_init(void) { }, }; + +#pragma mark - Module + +static bool backend_inited = false; + +static PyObject * +_init(PyObject *unused, PyObject *args) +{ + BEGIN_OBJC_ENTRY + + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + if (!NSApp) { + NSApp = [NSApplication sharedApplication]; + } + + if (![NSApp delegate]) { + appDelegate = [[MPLAppDelegate alloc] init]; + [NSApp setDelegate:appDelegate]; + } + + backend_inited = true; + + // Run our own event loop while waiting for stdin on the Python side + // this is needed to keep the application responsive while waiting for input + PyOS_InputHook = wait_for_stdin; + }); + + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +event_loop_is_running(PyObject *self) +{ + BEGIN_OBJC_ENTRY + + if (backend_inited) { + Py_RETURN_TRUE; + } else { + Py_RETURN_FALSE; + } + + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +wake_on_fd_write(PyObject *unused, PyObject *args) +{ + BEGIN_OBJC_ENTRY + int fd; + if (!PyArg_ParseTuple(args, "i", &fd)) { return NULL; } + + dispatch_source_t source = dispatch_source_create( + DISPATCH_SOURCE_TYPE_READ, fd, 0, + dispatch_get_main_queue() + ); + + dispatch_source_set_event_handler(source, ^{ + PyGILState_STATE gstate = PyGILState_Ensure(); + PyErr_CheckSignals(); + PyGILState_Release(gstate); + + dispatch_source_cancel(source); + }); + + dispatch_resume(source); + + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +stop(PyObject *self, PyObject *unused) +{ + BEGIN_OBJC_ENTRY + stopWithEvent(); + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +show(PyObject *self) +{ + BEGIN_OBJC_ENTRY + + // Iterating over FigureManagerHashTable will add the managers to the topmost + // autorelease pool, wrap in @autoreleasepool as -[NSApp run] is long-running. + @autoreleasepool { + [NSApp activateIgnoringOtherApps: YES]; + for (MPLFigureManager *manager in [FigureManagerHashTable allObjects]) { + [manager raise]; + } + } + + if ([NSApp isRunning]) { + PyErr_SetString(PyExc_RuntimeError, "An event loop is already running"); + return NULL; + } + + Py_BEGIN_ALLOW_THREADS + IsRunningFromShow = YES; + [NSApp run]; + IsRunningFromShow = NO; + Py_END_ALLOW_THREADS + + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + +static PyObject * +choose_save_file(PyObject *unused, PyObject *args) +{ + BEGIN_OBJC_ENTRY + + NSArray *strings = MPLGetStringArrayWithPySequence(args); + if ([strings count] != 3) { + PyErr_SetString(PyExc_RuntimeError, "Invalid arguments to choose_save_file"); + return NULL; + } + + NSString *title = [strings objectAtIndex:0]; + NSString *directory = [strings objectAtIndex:1]; + NSString *defaultFilename = [strings objectAtIndex:2]; + + NSSavePanel *panel = [NSSavePanel savePanel]; + [panel setTitle:title]; + [panel setDirectoryURL:[NSURL fileURLWithPath:directory isDirectory:YES]]; + [panel setNameFieldStringValue:defaultFilename]; + + __block NSModalResponse modalResponse; + modalResponse = [panel runModal]; + + if (modalResponse == NSModalResponseOK) { + NSString *filename = [[panel URL] path]; + if (!filename) { + PyErr_SetString(PyExc_RuntimeError, "Failed to obtain filename"); + return 0; + } + return PyUnicode_FromString([filename UTF8String]); + } + + END_OBJC_ENTRY + RETURN_NULL_OR_NONE +} + + +static int +ModuleExec(PyObject *m) +{ + static BOOL sLoaded = NO; + + // Use an os_unfair_lock as PyMutex requires Python >= 3.13 + static os_unfair_lock sLoadedLock = OS_UNFAIR_LOCK_INIT; + + BOOL wasModuleAlreadyLoaded = NO; + + os_unfair_lock_lock(&sLoadedLock); + wasModuleAlreadyLoaded = sLoaded; + sLoaded = YES; + os_unfair_lock_unlock(&sLoadedLock); + + if (wasModuleAlreadyLoaded) { + PyErr_SetString(PyExc_ImportError, + "cannot load module more than once per process"); + return -1; + } + + if (PyModule_AddType(m, &FigureCanvasType) + || PyModule_AddType(m, &FigureManagerType) + || PyModule_AddType(m, &NavigationToolbar2Type) + || PyModule_AddType(m, &TimerType)) { + return -1; + } + return 0; +} + static struct PyModuleDef moduledef = { .m_base = PyModuleDef_HEAD_INIT, - .m_name = "_macosx", - .m_doc = PyDoc_STR("Mac OS X native backend"), - .m_size = -1, + .m_name = "_macos", + .m_doc = PyDoc_STR("macOS native backend"), + .m_size = 0, + .m_slots = (PyModuleDef_Slot[]){ + {Py_mod_exec, ModuleExec}, + {Py_mod_multiple_interpreters, Py_MOD_MULTIPLE_INTERPRETERS_NOT_SUPPORTED}, +#ifdef Py_GIL_DISABLED + {Py_mod_gil, Py_MOD_GIL_NOT_USED}, +#endif + {0, NULL} + }, .m_methods = (PyMethodDef[]){ + {"_init", + (PyCFunction)_init, + METH_NOARGS, + PyDoc_STR( + "Perform a one-time initialization of the backend. Sets up the NSApp delegate" + "if one is not already present.")}, {"event_loop_is_running", (PyCFunction)event_loop_is_running, METH_NOARGS, @@ -1268,19 +1137,7 @@ static void lazy_init(void) { PyMODINIT_FUNC PyInit__macos(void) { - PyObject *m; - if (!(m = PyModule_Create(&moduledef)) - || PyModule_AddType(m, &FigureCanvasType) - || PyModule_AddType(m, &FigureManagerType) - || PyModule_AddType(m, &NavigationToolbar2Type) - || PyModule_AddType(m, &TimerType)) { - Py_XDECREF(m); - return NULL; - } -#ifdef Py_GIL_DISABLED - PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED); -#endif - return m; + return PyModuleDef_Init(&moduledef); } #pragma GCC visibility pop diff --git a/src/macos/meson.build b/src/macos/meson.build index 4e10d8486d00..a5e6e7e9528c 100644 --- a/src/macos/meson.build +++ b/src/macos/meson.build @@ -11,8 +11,55 @@ py3.extension_module( 'MPLUtils.m', ), dependencies: dependency('appleframeworks', modules: 'Cocoa'), - override_options: ['werror=true'], - objc_args: ['-fobjc-arc'], + override_options: ['werror=true','optimization=s'], + objc_args: [ + # Enable Objective-C ARC + '-fobjc-arc', + + # Add Apple-suggested flags enabled by default in Xcode + # which are not already covered by -Wall + '-Wblock-capture-autoreleasing', + '-Wcomma', + '-Wconditional-uninitialized', + '-Wdeprecated-declarations', + '-Wdeprecated-implementations', + '-Wdocumentation', + '-Wduplicate-method-match', + '-Werror=deprecated-objc-isa-usage', + '-Werror=non-modular-include-in-framework-module', + '-Werror=objc-root-class', + '-Werror=return-type', + '-Wimplicit-retain-self', + '-Wno-arc-repeated-use-of-weak', + '-Wno-conversion', + '-Wno-float-conversion', + '-Wno-four-char-constants', + '-Wno-implicit-atomic-properties', + '-Wno-implicit-fallthrough', + '-Wno-missing-braces', + '-Wno-missing-field-initializers', + '-Wno-missing-prototypes', + '-Wno-newline-eof', + '-Wno-objc-interface-ivars', + '-Wno-selector', + '-Wno-semicolon-before-method-body', + '-Wno-shadow', + '-Wno-sign-conversion', + '-Wno-strict-selector-match', + '-Wno-trigraphs', + '-Wno-unknown-pragmas', + '-Wno-unused-label', + '-Wno-unused-parameter', + '-Wnon-modular-include-in-framework-module', + '-Wpointer-sign', + '-Wprotocol', + '-Wquoted-include-in-framework-header', + '-Wshorten-64-to-32', + '-Wstrict-prototypes', + '-Wundeclared-selector', + '-Wunguarded-availability', + '-Wunreachable-code', + ], install: true, subdir: 'matplotlib/backends', ) From 2d5d3e3f166c760756d111c3b4058e5c8d913b01 Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Sat, 1 Aug 2026 20:31:17 -0700 Subject: [PATCH 06/20] Use existing app icon for now --- lib/matplotlib/backends/backend_macos.py | 5 +++-- src/macos/MPLAppDelegate.h | 5 +++++ src/macos/MPLAppDelegate.m | 17 ++++++++++++++++- src/macos/MPLUtils.h | 15 ++++++++++++++- src/macos/MPLUtils.m | 6 +++--- src/macos/_macos.m | 14 ++++++++++---- 6 files changed, 51 insertions(+), 11 deletions(-) diff --git a/lib/matplotlib/backends/backend_macos.py b/lib/matplotlib/backends/backend_macos.py index 987be193ea3c..3f3ad7de1966 100644 --- a/lib/matplotlib/backends/backend_macos.py +++ b/lib/matplotlib/backends/backend_macos.py @@ -25,8 +25,9 @@ def _allow_interrupt_macos(): @functools.lru_cache def _init_macos(): - # This will contain initialization code in a future PR - _macos._init() + data_path = cbook._get_data_path("images") + _macos._init({"matplotlib": str(data_path / "matplotlib.pdf")}) + class FigureCanvasMac(_macos.FigureCanvas, FigureCanvasBase): diff --git a/src/macos/MPLAppDelegate.h b/src/macos/MPLAppDelegate.h index 5a2c83372966..aca50b643263 100644 --- a/src/macos/MPLAppDelegate.h +++ b/src/macos/MPLAppDelegate.h @@ -1,9 +1,14 @@ #import +#import "MPLUtils.h" NS_ASSUME_NONNULL_BEGIN @interface MPLAppDelegate : NSObject +- (instancetype) initWithImageDictionary:(MPLStringDictionary *)imageDictionary; + +@property (nonatomic, readonly) MPLStringDictionary *imageDictionary; + @end NS_ASSUME_NONNULL_END diff --git a/src/macos/MPLAppDelegate.m b/src/macos/MPLAppDelegate.m index 0b60d3a5b8da..1d7e305dc028 100644 --- a/src/macos/MPLAppDelegate.m +++ b/src/macos/MPLAppDelegate.m @@ -15,9 +15,10 @@ @implementation MPLAppDelegate #pragma mark - Lifecycle -- (instancetype) init +- (instancetype) initWithImageDictionary:(MPLStringDictionary *)imageDictionary { if ((self = [super init])) { + _imageDictionary = imageDictionary; MPLLog("[Lifecycle] MPLAppDelegate<%p> init", self); } @@ -38,9 +39,11 @@ - (BOOL) applicationSupportsSecureRestorableState:(NSApplication *)app return YES; } + - (void) applicationWillFinishLaunching:(NSNotification *)notification { [self _buildMainMenu]; + [self _buildAppIcon]; [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; } @@ -130,4 +133,16 @@ - (void) _buildMainMenu } +- (void) _buildAppIcon +{ + NSString *imagePath = [_imageDictionary objectForKey:@"matplotlib"]; + NSURL *imageURL = imagePath ? [NSURL fileURLWithPath:imagePath] : nil; + + if (imageURL) { + NSImage *image = [[NSImage alloc] initWithContentsOfURL:imageURL]; + [NSApp setApplicationIconImage:image]; + } +} + + @end diff --git a/src/macos/MPLUtils.h b/src/macos/MPLUtils.h index 337e22f543d9..a68c30f3c9eb 100644 --- a/src/macos/MPLUtils.h +++ b/src/macos/MPLUtils.h @@ -6,6 +6,10 @@ NS_ASSUME_NONNULL_BEGIN +typedef NSArray MPLStringArray; +typedef NSDictionary MPLStringDictionary; + + /* When a method or function is NS_UNAVAILABLE, call MPLUnavailable() in the implementation to throw a runtime error. @@ -55,10 +59,19 @@ extern NSString * _Nullable MPLGetStringWithPySequence(PyObject * _Nullable pySe Returns nil and raises a Python exception if 'sequence' is not a sequence, any item is not a string, or any item could not be converted into an NSString. */ -extern NSArray * _Nullable MPLGetStringArrayWithPySequence( +extern MPLStringArray * _Nullable MPLGetStringArrayWithPySequence( PyObject * _Nullable pySequence ); +/* + Converts a Python dict to an NSDictionary, keys/values must be strings. + Returns nil and raises a Python exception if 'dict' is not a dict, any + key/value was not a str, or any str could not be converted into an NSString. +*/ +extern MPLStringDictionary * _Nullable MPLGetStringDictionaryWithPyDict( + PyObject * _Nullable dict +); + /* Calls getbuffer() on a Python object and returns the buffer as an NSData. diff --git a/src/macos/MPLUtils.m b/src/macos/MPLUtils.m index f56096a40084..512ec2231a36 100644 --- a/src/macos/MPLUtils.m +++ b/src/macos/MPLUtils.m @@ -101,7 +101,7 @@ void MPLCallMethod(PyObject *pyObject, const char *name, char const *format, ... } -NSArray *MPLGetStringArrayWithPySequence(PyObject *pySequence) +MPLStringArray *MPLGetStringArrayWithPySequence(PyObject *pySequence) { if (!pySequence) { if (!PyErr_Occurred()) PyErr_SetString(PyExc_RuntimeError, "Input is NULL"); @@ -139,7 +139,7 @@ void MPLCallMethod(PyObject *pyObject, const char *name, char const *format, ... NSString *MPLGetStringWithPySequence(PyObject *pySequence) { - NSArray *array = MPLGetStringArrayWithPySequence(pySequence); + MPLStringArray *array = MPLGetStringArrayWithPySequence(pySequence); if (array && ([array count] != 1)) { PyErr_SetString(PyExc_RuntimeError, "Input is not a sequence of exactly one string"); @@ -150,7 +150,7 @@ void MPLCallMethod(PyObject *pyObject, const char *name, char const *format, ... } -NSDictionary *MPLGetStringDictionaryWithPyDict(PyObject *dict) +MPLStringDictionary *MPLGetStringDictionaryWithPyDict(PyObject *dict) { if (!dict) { if (!PyErr_Occurred()) PyErr_SetString(PyExc_RuntimeError, "Input is NULL"); diff --git a/src/macos/_macos.m b/src/macos/_macos.m index fac26032118b..280e1c6bfb94 100755 --- a/src/macos/_macos.m +++ b/src/macos/_macos.m @@ -691,7 +691,7 @@ static int wait_for_stdin(void) { { BEGIN_OBJC_ENTRY - NSArray *strings = MPLGetStringArrayWithPySequence(args); + MPLStringArray *strings = MPLGetStringArrayWithPySequence(args); if ([strings count] != 4) return NULL; [self->object addItemWithTitle: [strings objectAtIndex:0] @@ -913,6 +913,12 @@ static int wait_for_stdin(void) { { BEGIN_OBJC_ENTRY + PyObject *imagesDict; + if (!PyArg_ParseTuple(args, "O!", &PyDict_Type, &imagesDict)) { return NULL; } + + NSDictionary *imagesDictionary = MPLGetStringDictionaryWithPyDict(imagesDict); + if (!imagesDictionary) { return NULL; } + static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ if (!NSApp) { @@ -920,7 +926,7 @@ static int wait_for_stdin(void) { } if (![NSApp delegate]) { - appDelegate = [[MPLAppDelegate alloc] init]; + appDelegate = [[MPLAppDelegate alloc] initWithImageDictionary:imagesDictionary]; [NSApp setDelegate:appDelegate]; } @@ -1019,7 +1025,7 @@ static int wait_for_stdin(void) { { BEGIN_OBJC_ENTRY - NSArray *strings = MPLGetStringArrayWithPySequence(args); + MPLStringArray *strings = MPLGetStringArrayWithPySequence(args); if ([strings count] != 3) { PyErr_SetString(PyExc_RuntimeError, "Invalid arguments to choose_save_file"); return NULL; @@ -1097,7 +1103,7 @@ static int wait_for_stdin(void) { .m_methods = (PyMethodDef[]){ {"_init", (PyCFunction)_init, - METH_NOARGS, + METH_VARARGS, PyDoc_STR( "Perform a one-time initialization of the backend. Sets up the NSApp delegate" "if one is not already present.")}, From 23ce2c89fe8c69eeff88b6d8a083b7add56fe67d Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Sat, 1 Aug 2026 20:57:58 -0700 Subject: [PATCH 07/20] Fix too many lines --- lib/matplotlib/backends/backend_macos.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/matplotlib/backends/backend_macos.py b/lib/matplotlib/backends/backend_macos.py index 3f3ad7de1966..8293f6b28d1f 100644 --- a/lib/matplotlib/backends/backend_macos.py +++ b/lib/matplotlib/backends/backend_macos.py @@ -29,7 +29,6 @@ def _init_macos(): _macos._init({"matplotlib": str(data_path / "matplotlib.pdf")}) - class FigureCanvasMac(_macos.FigureCanvas, FigureCanvasBase): # docstring inherited From 32a4e6792ed0e9c511a9cc9a794a4431b4791f44 Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Sun, 2 Aug 2026 07:58:56 -0700 Subject: [PATCH 08/20] Add "legacymac" and "macos" --- lib/matplotlib/tests/test_backend_registry.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/tests/test_backend_registry.py b/lib/matplotlib/tests/test_backend_registry.py index 2bd8e161bd6b..b05f17663cb0 100644 --- a/lib/matplotlib/tests/test_backend_registry.py +++ b/lib/matplotlib/tests/test_backend_registry.py @@ -41,8 +41,8 @@ def test_list_builtin(): assert not has_duplicates(backends) # Compare using sets as order is not important assert {*backends} == { - 'gtk3agg', 'gtk3cairo', 'gtk4agg', 'gtk4cairo', 'macosx', 'nbagg', 'notebook', - 'qtagg', 'qtcairo', 'qt5agg', 'qt5cairo', 'tkagg', + 'gtk3agg', 'gtk3cairo', 'gtk4agg', 'gtk4cairo', 'legacymac', 'macos', 'macosx', + 'nbagg', 'notebook', 'qtagg', 'qtcairo', 'qt5agg', 'qt5cairo', 'tkagg', 'tkcairo', 'webagg', 'wx', 'wxagg', 'wxcairo', 'agg', 'cairo', 'pdf', 'pgf', 'ps', 'svg', 'template', } @@ -52,8 +52,8 @@ def test_list_builtin(): 'filter,expected', [ (BackendFilter.INTERACTIVE, - ['gtk3agg', 'gtk3cairo', 'gtk4agg', 'gtk4cairo', 'macosx', 'nbagg', 'notebook', - 'qtagg', 'qtcairo', 'qt5agg', 'qt5cairo', 'tkagg', + ['gtk3agg', 'gtk3cairo', 'gtk4agg', 'gtk4cairo', 'legacymac', 'macos', 'macosx', + 'nbagg', 'notebook', 'qtagg', 'qtcairo', 'qt5agg', 'qt5cairo', 'tkagg', 'tkcairo', 'webagg', 'wx', 'wxagg', 'wxcairo']), (BackendFilter.NON_INTERACTIVE, ['agg', 'cairo', 'pdf', 'pgf', 'ps', 'svg', 'template']), @@ -71,7 +71,7 @@ def test_list_gui_frameworks(): assert not has_duplicates(frameworks) # Compare using sets as order is not important assert {*frameworks} == { - "gtk3", "gtk4", "macosx", "qt", "qt5", "qt6", "tk", "wx", + "gtk3", "gtk4", "legacymac", "macos", "macosx", "qt", "qt5", "qt6", "tk", "wx", } From 5c2a30499a8744cd9183c73f392ec05715c0ef37 Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Sun, 2 Aug 2026 07:59:33 -0700 Subject: [PATCH 09/20] Add "macos" to RcGroupKeyType --- lib/matplotlib/typing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/matplotlib/typing.py b/lib/matplotlib/typing.py index e5bc9ce45269..1511525734e4 100644 --- a/lib/matplotlib/typing.py +++ b/lib/matplotlib/typing.py @@ -565,6 +565,7 @@ "keymap", "legend", "lines", + "macos", "macosx", "markers", "mathtext", From 946a4e60ff849e1764e8aa280aef9dc18aef1bc1 Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Sun, 2 Aug 2026 08:02:54 -0700 Subject: [PATCH 10/20] Fixing long line --- lib/matplotlib/tests/test_backend_registry.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/matplotlib/tests/test_backend_registry.py b/lib/matplotlib/tests/test_backend_registry.py index b05f17663cb0..29f3362a16d5 100644 --- a/lib/matplotlib/tests/test_backend_registry.py +++ b/lib/matplotlib/tests/test_backend_registry.py @@ -52,9 +52,9 @@ def test_list_builtin(): 'filter,expected', [ (BackendFilter.INTERACTIVE, - ['gtk3agg', 'gtk3cairo', 'gtk4agg', 'gtk4cairo', 'legacymac', 'macos', 'macosx', - 'nbagg', 'notebook', 'qtagg', 'qtcairo', 'qt5agg', 'qt5cairo', 'tkagg', - 'tkcairo', 'webagg', 'wx', 'wxagg', 'wxcairo']), + ['gtk3agg', 'gtk3cairo', 'gtk4agg', 'gtk4cairo', 'legacymac', 'macos', + 'macosx', 'nbagg', 'notebook', 'qtagg', 'qtcairo', 'qt5agg', 'qt5cairo', + 'tkagg', 'tkcairo', 'webagg', 'wx', 'wxagg', 'wxcairo']), (BackendFilter.NON_INTERACTIVE, ['agg', 'cairo', 'pdf', 'pgf', 'ps', 'svg', 'template']), ] From ef2fa09058dc34dba502ddffcc8f9f0b0afd630b Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Sun, 2 Aug 2026 08:57:26 -0700 Subject: [PATCH 11/20] Add "LegacyMac" and "macOS" to docstring --- lib/matplotlib/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 2a83cc1f1091..1aa1908b1582 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -1227,8 +1227,9 @@ def use(backend, *, force=True): backend names, which are case-insensitive: - interactive backends: - GTK3Agg, GTK3Cairo, GTK4Agg, GTK4Cairo, MacOSX, nbAgg, notebook, QtAgg, - QtCairo, TkAgg, TkCairo, WebAgg, WX, WXAgg, WXCairo, Qt5Agg, Qt5Cairo + GTK3Agg, GTK3Cairo, GTK4Agg, GTK4Cairo, LegacyMac, macOS, MacOSX, + nbAgg, notebook, QtAgg, QtCairo, TkAgg, TkCairo, WebAgg, WX, WXAgg, + WXCairo, Qt5Agg, Qt5Cairo - non-interactive backends: agg, cairo, pdf, pgf, ps, svg, template From 8968f2a46b9f3680123fab68fafb73057d3235d7 Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Sun, 2 Aug 2026 08:57:45 -0700 Subject: [PATCH 12/20] Fix shim for test case failure --- lib/matplotlib/backends/backend_macosx.py | 28 +++++++++++++---------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/lib/matplotlib/backends/backend_macosx.py b/lib/matplotlib/backends/backend_macosx.py index 99f92919ee47..8228df4254ff 100644 --- a/lib/matplotlib/backends/backend_macosx.py +++ b/lib/matplotlib/backends/backend_macosx.py @@ -1,21 +1,25 @@ -import sys from . import backend_legacymac as _legacymac from matplotlib.backend_bases import _Backend -_RENAMES = { - 'FigureCanvasMac': 'FigureCanvasLegacyMac', - 'FigureManagerMac': 'FigureManagerLegacyMac', - 'NavigationToolbar2': 'NavigationToolbar2LegacyMac' -} -_mod = sys.modules[__name__] -for _new, _old in _RENAMES.items(): - setattr(_mod, _new, getattr(_legacymac, _old)) -del _mod, _new, _old +class TimerMac(_legacymac.TimerLegacyMac): + pass + + +class FigureCanvasMac(_legacymac.FigureCanvasLegacyMac): + pass + + +class FigureManagerMac(_legacymac.FigureManagerLegacyMac): + pass + + +class NavigationToolbar2Mac(_legacymac.NavigationToolbar2LegacyMac): + pass @_Backend.export class _BackendMac(_Backend): - FigureCanvas = _legacymac.FigureCanvasLegacyMac - FigureManager = _legacymac.FigureManagerLegacyMac + FigureCanvas = FigureCanvasMac + FigureManager = FigureManagerMac mainloop = _legacymac.FigureManagerLegacyMac.start_main_loop From 6199da9f11d97781cac0409497cbb17dfc9c1078 Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Sat, 8 Aug 2026 10:56:44 -0700 Subject: [PATCH 13/20] Address AI feedback and pull some changes from rework branch --- .clang-tidy | 1 + lib/matplotlib/backends/backend_macos.py | 34 ++-- lib/matplotlib/cbook.py | 2 +- src/macos/MPLAppDelegate.m | 4 +- src/macos/MPLFigureCanvas.h | 6 +- src/macos/MPLFigureCanvas.m | 151 +++++++++-------- src/macos/MPLFigureManager.m | 29 ++-- src/macos/MPLNavigationToolbar2.m | 4 +- src/macos/MPLUtils.h | 11 ++ src/macos/MPLUtils.m | 68 +++++++- src/macos/_macos.m | 202 ++++++++++++----------- 11 files changed, 306 insertions(+), 206 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index 64263ec96b7b..c549b2c6d370 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -26,6 +26,7 @@ Checks: > -bugprone-throwing-static-initialization, -clang-analyzer-optin.cplusplus.UninitializedObject, -clang-analyzer-optin.performance.Padding, + -clang-analyzer-optin.osx.cocoa.localizability.NonLocalizedStringChecker # Only report findings in matplotlib's own src/ headers, not in pybind11, # Python.h, agg, or other vendored includes. diff --git a/lib/matplotlib/backends/backend_macos.py b/lib/matplotlib/backends/backend_macos.py index 8293f6b28d1f..c4482577239b 100644 --- a/lib/matplotlib/backends/backend_macos.py +++ b/lib/matplotlib/backends/backend_macos.py @@ -78,16 +78,20 @@ def _handle_resize(self, width, height): ResizeEvent("resize_event", self)._process() self.draw_idle() + def _mpl_button(self, button): + """Converts from AppKit buttonNumber to a MouseButton""" + mod_table = ( + MouseButton.LEFT, + MouseButton.RIGHT, + MouseButton.MIDDLE, + MouseButton.BACK, + MouseButton.FORWARD, + ) + return mod_table[button] if 0 <= button < len(mod_table) else None + 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} + """Converts from AppKit pressedMouseButtons to a MouseButton set""" + return {self._mpl_button(i) for i in range(5) if buttons & (1 << i)} def _mpl_modifiers(self, modifiers): """Converts from AppKit modifierFlags to a list of strings""" @@ -112,12 +116,16 @@ def _handle_mouse_exited(self, x, y, modifiers): 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() + button=self._mpl_button(button) + if button is not None: + 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() + button=self._mpl_button(button) + if button is not None: + 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, diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index ca63f954265b..3c07a52dfb91 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -101,7 +101,7 @@ def _get_running_interactive_framework(): # Preemptively break reference cycle between locals and the frame. del frame macos = sys.modules.get("matplotlib.backends._macos") - if macos and macos.event_loop_is_running(): + if macos and macos.is_initialized(): return "macos" macosx = sys.modules.get("matplotlib.backends._macosx") if macosx and macosx.event_loop_is_running(): diff --git a/src/macos/MPLAppDelegate.m b/src/macos/MPLAppDelegate.m index 1d7e305dc028..1bea2c236abc 100644 --- a/src/macos/MPLAppDelegate.m +++ b/src/macos/MPLAppDelegate.m @@ -4,7 +4,7 @@ // These are standard selectors which AppKit never exposes in any header. // They are typically shown only in Interface Builder as a potential action. -@interface NSObject () +@interface NSObject (MissingPublicMethods) - (void) closeAll:(id)sender; - (void) undo:(id)sender; - (void) redo:(id)sender; @@ -68,8 +68,6 @@ - (void) _buildMainMenu NSMenuItem *menuItem = [[NSMenuItem alloc] init]; [menuItem setTitle:title]; [menuItem setSubmenu:menu]; - [menuItem setTarget:menu]; - [menuItem setAction:@selector(submenuAction:)]; [mainMenu addItem:menuItem]; currentMenu = menu; diff --git a/src/macos/MPLFigureCanvas.h b/src/macos/MPLFigureCanvas.h index eebc769f5d53..27009da3516b 100644 --- a/src/macos/MPLFigureCanvas.h +++ b/src/macos/MPLFigureCanvas.h @@ -9,9 +9,9 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype) initWithFrame:(NSRect)rect; -- (void) updateLayerContentsWithBuffer: (NSData *) buffer - deviceWidth: (size_t) deviceWidth - deviceHeight: (size_t) deviceHeight; +- (void) updateLayerContentsWithDataProvider: (CGDataProviderRef) provider + deviceWidth: (size_t) deviceWidth + deviceHeight: (size_t) deviceHeight; - (void) updateCursorType:(int)cursorType; - (void) updateRubberbandWithDeviceX0:(int)x0 y0:(int)y0 x1:(int)x1 y1:(int)y1; diff --git a/src/macos/MPLFigureCanvas.m b/src/macos/MPLFigureCanvas.m index 9a16fb3e29b6..aae49b026a89 100644 --- a/src/macos/MPLFigureCanvas.m +++ b/src/macos/MPLFigureCanvas.m @@ -1,29 +1,6 @@ #import "MPLFigureCanvas.h" #import "MPLUtils.h" - - -@interface MPLRubberbandView : NSView -@end - - -@implementation MPLRubberbandView - -- (void) drawRect:(NSRect)dirtyRect -{ - NSBezierPath *path = [NSBezierPath bezierPathWithRect:[self bounds]]; - CGFloat dashPattern[2] = { 3.0, 3.0 }; - - [path setLineDash:dashPattern count:2 phase:0]; - [[NSColor whiteColor] setStroke]; - [path stroke]; - - [path setLineDash:dashPattern count:2 phase:3]; - [[NSColor blackColor] setStroke]; - [path stroke]; -} - -@end - +#import @interface MPLFigureCanvas () @end @@ -33,7 +10,8 @@ @implementation MPLFigureCanvas { BOOL _isLeftMouseDown; BOOL _isHandCursorActive; NSEventModifierFlags _previousModifierFlags; - MPLRubberbandView *_rubberbandView; + CALayer *_canvasLayer; + CALayer *_rubberbandLayer; BOOL _needsDrawOnNextDisplayLayer; } @@ -45,14 +23,21 @@ - (instancetype) initWithFrame:(NSRect)rect NSTrackingActiveInKeyWindow | NSTrackingInVisibleRect ); - CALayer *layer = [CALayer layer]; - [layer setDelegate:self]; - [layer setContentsGravity:kCAGravityResize]; - [layer setBackgroundColor:[[NSColor whiteColor] CGColor]]; - [layer setOpaque:YES]; + CALayer *canvasLayer = [CALayer layer]; + [canvasLayer setDelegate:self]; + [canvasLayer setContentsGravity:kCAGravityResize]; + [canvasLayer setBackgroundColor:[[NSColor whiteColor] CGColor]]; + [canvasLayer setOpaque:YES]; - [self setLayer:layer]; + [self setLayer:canvasLayer]; [self setWantsLayer:YES]; + + CALayer *rubberbandLayer = [CALayer layer]; + [rubberbandLayer setDelegate:self]; + [rubberbandLayer setNeedsDisplayOnBoundsChange:YES]; + + _canvasLayer = canvasLayer; + _rubberbandLayer = rubberbandLayer; [self addTrackingArea:[[NSTrackingArea alloc] initWithRect: CGRectZero options: options @@ -79,6 +64,9 @@ - (void) viewDidChangeBackingProperties CGFloat scaleFactor = [[self window] backingScaleFactor]; if (!scaleFactor) scaleFactor = 1; + [_canvasLayer setContentsScale:scaleFactor]; + [_rubberbandLayer setContentsScale:scaleFactor]; + int width, height; [self _getDeviceSizeWithSize:[self frame].size width:&width height:&height]; @@ -109,8 +97,13 @@ - (BOOL) acceptsFirstResponder - (void) displayLayer:(CALayer *)layer { - int needsDraw = _needsDrawOnNextDisplayLayer ? 1 : 0; - MPLCallMethod(_pyObject, "_handle_display_layer", "i", needsDraw); + if (layer == _canvasLayer) { + int needsDraw = _needsDrawOnNextDisplayLayer ? 1 : 0; + MPLCallMethod(_pyObject, "_handle_display_layer", "i", needsDraw); + + } else if (layer == _rubberbandLayer) { + [self _displayRubberbandLayer]; + } } @@ -166,27 +159,53 @@ - (void) _updateHandCursor } -- (void) _updateRubberbandViewWithFrame:(CGRect)frame +- (void) _updateRubberbandLayerWithFrame:(CGRect)frame { if (CGRectIsEmpty(frame)) { - [_rubberbandView removeFromSuperview]; - _rubberbandView = nil; - - } else if (!_rubberbandView) { - _rubberbandView = [[MPLRubberbandView alloc] initWithFrame:frame]; - [self addSubview:_rubberbandView]; + [_rubberbandLayer removeFromSuperlayer]; + [_rubberbandLayer setContents:nil]; } else { - [_rubberbandView setFrame:frame]; + if (![_rubberbandLayer superlayer]) { + [_canvasLayer addSublayer:_rubberbandLayer]; + } + + [_rubberbandLayer setFrame:frame]; } } +- (void) _displayRubberbandLayer +{ + CGRect bounds = [_rubberbandLayer bounds]; + CGFloat contentsScale = [_rubberbandLayer contentsScale]; + + CGImageRef contents = MPLCreateImage(bounds.size, contentsScale, ^(CGContextRef context) { + CGRect strokeRect = CGRectInset(bounds, 0.5, 0.5); + CGFloat dashPattern[2] = { 3.0, 3.0 }; + + CGContextSetGrayStrokeColor(context, 1.0, 1.0); + CGContextSetLineDash(context, 0.0, dashPattern, 2); + CGContextStrokeRect(context, strokeRect); + + CGContextSetGrayStrokeColor(context, 0.0, 1.0); + CGContextSetLineDash(context, 3.0, dashPattern, 2); + CGContextStrokeRect(context, strokeRect); + }); + + [_rubberbandLayer setContents:(__bridge id)contents]; + + CGImageRelease(contents); +} + + #pragma mark - Keyboard Events - (NSString *) _mappedStringWithCharacters:(NSString *)characters { - NSDictionary *keyMap = @{ + static NSDictionary *sKeyMap = nil; + + if (!sKeyMap) sKeyMap = @{ @( NSLeftArrowFunctionKey ): @"left", @( NSRightArrowFunctionKey ): @"right", @( NSUpArrowFunctionKey ): @"up", @( NSDownArrowFunctionKey ): @"down", @( NSF1FunctionKey ): @"f1", @( NSF2FunctionKey ): @"f2", @@ -210,7 +229,7 @@ - (NSString *) _mappedStringWithCharacters:(NSString *)characters }; return ([characters length] > 0) ? - [keyMap objectForKey:@( [characters characterAtIndex:0] )] : + [sKeyMap objectForKey:@( [characters characterAtIndex:0] )] : nil; } @@ -333,11 +352,6 @@ - (void) _handleMouseDownOrUp:(NSEvent *)event isPress:(BOOL)isPress [self _updateHandCursor]; } - // Map AppKit buttonNumber to MPL MouseButton - buttonNumber++; - if (buttonNumber == 2) buttonNumber = 3; - else if (buttonNumber == 3) buttonNumber = 2; - if (isPress) { MPLCallMethod(_pyObject, "_handle_mouse_down", "iilki", x, y, buttonNumber, modifierFlags, @@ -412,18 +426,10 @@ - (void) otherMouseDragged:(NSEvent *)event { [self mouseMoved:event]; } #pragma mark - Public Methods -- (void) updateLayerContentsWithBuffer: (NSData *) buffer - deviceWidth: (size_t) deviceWidth - deviceHeight: (size_t) deviceHeight; +- (void) updateLayerContentsWithDataProvider: (CGDataProviderRef) provider + deviceWidth: (size_t) deviceWidth + deviceHeight: (size_t) deviceHeight { - CFDataRef cfBuffer = (__bridge CFDataRef)buffer; - CGDataProviderRef provider = CGDataProviderCreateWithCFData(cfBuffer); - - if (!provider) { - PyErr_SetString(PyExc_RuntimeError, "CGDataProviderCreateWithCFData() failed"); - return; - } - CGColorSpaceRef colorSpace = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); CGBitmapInfo bitmapInfo = 0 | kCGBitmapByteOrderDefault | kCGImageAlphaLast; @@ -434,32 +440,31 @@ - (void) updateLayerContentsWithBuffer: (NSData *) buffer ) : NULL; if (contents) { - [[self layer] setContents:(__bridge id)contents]; + [_canvasLayer setContents:(__bridge id)contents]; } CGColorSpaceRelease(colorSpace); - CGDataProviderRelease(provider); CGImageRelease(contents); } - (void) updateCursorType:(int)cursorType { - // Keep track of whether this view has the hand cursor active rather - // than comparing against the process-level [NSCursor currentCursor] _isHandCursorActive = (cursorType == 4); if (_isHandCursorActive) { [self _updateHandCursor]; } else { - NSCursor *cursor = [@{ - @(1): [NSCursor arrowCursor], - @(2): [NSCursor pointingHandCursor], - @(3): [NSCursor crosshairCursor], - @(6): [NSCursor resizeLeftRightCursor], - @(7): [NSCursor resizeUpDownCursor], - } objectForKey:@(cursorType)]; + NSCursor *cursor; + + if (cursorType == 1) cursor = [NSCursor arrowCursor]; + else if (cursorType == 2) cursor = [NSCursor pointingHandCursor]; + else if (cursorType == 3) cursor = [NSCursor crosshairCursor]; + // 4 is Cursors.MOVE - handled above + // 5 is Cursors.WAIT - macOS automatically shows the busy cursor as needed + else if (cursorType == 6) cursor = [NSCursor resizeLeftRightCursor]; + else if (cursorType == 7) cursor = [NSCursor resizeUpDownCursor]; [cursor set]; } @@ -470,13 +475,13 @@ - (void) updateRubberbandWithDeviceX0:(int)x0 y0:(int)y0 x1:(int)x1 y1:(int)y1 { CGRect rect = CGRectStandardize(CGRectMake(x0, y0, x1 - x0, y1 - y0)); CGRect rubberbandFrame = [self convertRectFromBacking:rect]; - [self _updateRubberbandViewWithFrame:rubberbandFrame]; + [self _updateRubberbandLayerWithFrame:rubberbandFrame]; } - (void) removeRubberband { - [self _updateRubberbandViewWithFrame:CGRectZero]; + [self _updateRubberbandLayerWithFrame:CGRectZero]; } @@ -484,7 +489,7 @@ - (void) requestDisplayLayerWithNeedsDraw:(BOOL)needsDraw { if ([NSThread isMainThread]) { _needsDrawOnNextDisplayLayer = needsDraw; - [[self layer] setNeedsDisplay]; + [_canvasLayer setNeedsDisplay]; } else { __weak id weakSelf = self; diff --git a/src/macos/MPLFigureManager.m b/src/macos/MPLFigureManager.m index dbe4f77aa427..816a09f8e2f9 100644 --- a/src/macos/MPLFigureManager.m +++ b/src/macos/MPLFigureManager.m @@ -48,24 +48,25 @@ - (instancetype) initWithFigureCanvas:(MPLFigureCanvas *)figureCanvas backing: NSBackingStoreBuffered defer: YES]; - [window setDelegate:self]; - [window makeFirstResponder:figureCanvas]; - [window setReleasedWhenClosed:NO]; + if ((self = [super initWithWindow:window])) { + [window setDelegate:self]; + [window makeFirstResponder:figureCanvas]; + [window setReleasedWhenClosed:NO]; - // Match the window's color space to our Agg buffer. - // This prevents an in-process color space conversion when compositing and - // may allow for a GPU-accelerated conversion at the WindowServer level. - [window setColorSpace:[NSColorSpace sRGBColorSpace]]; + // Match the window's color space to our Agg buffer. + // This prevents an in-process color space conversion when compositing and + // may allow for a GPU-accelerated conversion at the WindowServer level. + [window setColorSpace:[NSColorSpace sRGBColorSpace]]; - // We want to handle the cursor changes from within MPL with set_cursor() ourselves - [window disableCursorRects]; + // We want to handle the cursor changes from within MPL with set_cursor() ourselves + [window disableCursorRects]; - [figureCanvas setAutoresizingMask:NSViewWidthSizable|NSViewHeightSizable]; - [[window contentView] addSubview:figureCanvas]; + [figureCanvas setAutoresizingMask:NSViewWidthSizable|NSViewHeightSizable]; + [[window contentView] addSubview:figureCanvas]; - if ((self = [super initWithWindow:window])) { [figureCanvas setManager:self]; _figureCanvas = figureCanvas; + MPLLog("[Lifecycle] MPLFigureManager<%p> init", self); } @@ -99,7 +100,7 @@ - (void) windowWillClose:(NSNotification *)notification } -- (BOOL) windowShouldClose:(NSNotification *)notification +- (BOOL) windowShouldClose:(NSWindow *)sender { MPLCallMethod(_pyObject, "_handle_window_should_close", ""); return YES; @@ -130,7 +131,7 @@ - (void) resizeToDeviceWidth:(int)width height:(int)height { NSWindow *window = [self window]; CGRect rect = CGRectMake(0, 0, width, height); - [window convertRectFromBacking:rect]; + rect = [window convertRectFromBacking:rect]; if (_toolbar) { rect.size.height += [_toolbar frame].size.height; diff --git a/src/macos/MPLNavigationToolbar2.m b/src/macos/MPLNavigationToolbar2.m index fa335a62a910..878be0f631a8 100644 --- a/src/macos/MPLNavigationToolbar2.m +++ b/src/macos/MPLNavigationToolbar2.m @@ -106,8 +106,8 @@ - (void) _callMethodForButton:(id)sender NSUInteger index = [_buttons indexOfObject:sender]; if (index != NSNotFound) { - NSString *callbackName = [_callbackNames objectAtIndex:index]; - MPLCallMethod(_pyObject, [callbackName UTF8String], ""); + const char *callbackName = [[_callbackNames objectAtIndex:index] UTF8String]; + if (callbackName) MPLCallMethod(_pyObject, callbackName, ""); } } diff --git a/src/macos/MPLUtils.h b/src/macos/MPLUtils.h index a68c30f3c9eb..ac9cb386c12e 100644 --- a/src/macos/MPLUtils.h +++ b/src/macos/MPLUtils.h @@ -89,4 +89,15 @@ extern NSData * _Nullable MPLGetBufferWithPyObject( ); +/* + Create a sRGB+alpha image of the specified width, height, and scale factor. + (0, 0) corresponds to the upper-left corner. +*/ +extern CGImageRef _Nullable MPLCreateImage( + CGSize size, + CGFloat scale, + void (^callback)(CGContextRef) +); + + NS_ASSUME_NONNULL_END diff --git a/src/macos/MPLUtils.m b/src/macos/MPLUtils.m index 512ec2231a36..b9ce46846e43 100644 --- a/src/macos/MPLUtils.m +++ b/src/macos/MPLUtils.m @@ -86,13 +86,23 @@ void MPLCallMethod(PyObject *pyObject, const char *name, char const *format, ... return nil; } - const char *cString = PyUnicode_AsUTF8(pyString); + Py_ssize_t size = 0; + const char *cString = PyUnicode_AsUTF8AndSize(pyString, &size); + if (!cString) { - // PyUnicode_AsUTF8() should set error in this case + // PyUnicode_AsUTF8AndSize() should set error in this case + return nil; + } + + // This should never happen, but we are about to cast from signed to unsigned + if (size < 0) { + PyErr_SetString(PyExc_RuntimeError, "Size is less than 0"); return nil; } - NSString *result = [NSString stringWithUTF8String:cString]; + NSUInteger length = (NSUInteger)size; + NSString *result = [[NSString alloc] initWithBytes:cString length:length encoding:NSUTF8StringEncoding]; + if (!result) { PyErr_SetString(PyExc_RuntimeError, "Could not create NSString"); } @@ -223,3 +233,55 @@ void MPLCallMethod(PyObject *pyObject, const char *name, char const *format, ... length: buffer->len deallocator: deallocator]; } + + +CGImageRef sCreateImage( + CGSize size, CGFloat scale, BOOL flipped, + CFStringRef colorSpaceName, size_t componentCount, CGBitmapInfo bitmapInfo, + void (^callback)(CGContextRef) +) { + if (size.width <= 0 || size.height <= 0) return NULL; + + CGColorSpaceRef colorSpace = CGColorSpaceCreateWithName(colorSpaceName); + if (!colorSpace) return NULL; + + size_t pixelsWide = size.width * scale; + size_t pixelsHigh = size.height * scale; + + CGContextRef context = CGBitmapContextCreate( + NULL, pixelsWide, pixelsHigh, + 8, pixelsWide * componentCount, colorSpace, + bitmapInfo + ); + + CGImageRef result = NULL; + + if (context) { + if (flipped) { + CGContextTranslateCTM(context, 0, pixelsHigh); + CGContextScaleCTM(context, scale, -scale); + } + + NSGraphicsContext *savedContext = [NSGraphicsContext currentContext]; + [NSGraphicsContext setCurrentContext:[NSGraphicsContext graphicsContextWithCGContext:context flipped:flipped]]; + + callback(context); + + [NSGraphicsContext setCurrentContext:savedContext]; + + result = CGBitmapContextCreateImage(context); + CFRelease(context); + } + + CGColorSpaceRelease(colorSpace); + + return result; +} + + +CGImageRef MPLCreateImage(CGSize size, CGFloat scale, void (^callback)(CGContextRef)) +{ + CGBitmapInfo bitmapInfo = 0 | kCGImageAlphaPremultipliedFirst | kCGImageByteOrder32Little; + return sCreateImage(size, scale, YES, kCGColorSpaceSRGB, 4, bitmapInfo, callback); +} + diff --git a/src/macos/_macos.m b/src/macos/_macos.m index 280e1c6bfb94..d776d9773792 100755 --- a/src/macos/_macos.m +++ b/src/macos/_macos.m @@ -32,7 +32,7 @@ @autoreleasepool { @try { #define END_OBJC_ENTRY \ - } @catch (NSException *e) { errSetException(e); } } + } @catch (NSException *e) { sErrSetException(e); } } #define RETURN_NULL_OR_NONE \ if (PyErr_Occurred()) { \ @@ -43,23 +43,28 @@ /* Variable for our delegate since it needs a +1 reference count. */ -static id appDelegate = nil; +static id sAppDelegate = nil; /* Variables to keep track of state and window count for show() */ -static BOOL IsRunningFromShow = NO; -static NSHashTable *FigureManagerHashTable = nil; +static BOOL sIsRunningFromShow = NO; +static NSHashTable *sFigureManagerHashTable = nil; -// Global variable to store the original SIGINT handler -static PyOS_sighandler_t originalSigintAction = NULL; +// Set to YES in _init() if initialization was successful +static BOOL sIsInitialized = NO; // Convert an Objective-C exception into a Python RuntimeError -static void errSetException(NSException *exception) { - PyErr_SetString(PyExc_RuntimeError, [[exception reason] UTF8String]); +static void sErrSetException(NSException *exception) +{ + const char *cString = [[exception reason] UTF8String]; + if (!cString) cString = [[exception name] UTF8String]; + if (!cString) cString = "Objective-C Exception"; + + PyErr_SetString(PyExc_RuntimeError, cString); } // Old implementation, goes away with MPLEventLoop PR -static void stopWithEvent(void) +static void sStopWithEvent(void) { [NSApp stop: nil]; // Post an event to trigger the actual stopping. @@ -81,13 +86,14 @@ static void stopWithEvent(void) // Old implementation, goes away with MPLEventLoop PR -static void handleSigint(int signal) +static void sHandleSigint(int signal) { - stopWithEvent(); + sStopWithEvent(); } // Old implementation, goes away with MPLEventLoop PR -static void flushEvents(void) { +static void flushEvents(void) +{ while (true) { @autoreleasepool { NSEvent* event = [NSApp nextEventMatchingMask: NSEventMaskAny @@ -103,7 +109,8 @@ static void flushEvents(void) { } // Old implementation, goes away with MPLEventLoop PR -static int wait_for_stdin(void) { +static int sInputHook(void) +{ BEGIN_OBJC_ENTRY // Short circuit if no windows are active @@ -118,7 +125,7 @@ static int wait_for_stdin(void) { } // Set up a SIGINT handler to interrupt the event loop if ctrl+c comes in too - originalSigintAction = PyOS_setsig(SIGINT, handleSigint); + PyOS_sighandler_t originalSigintHandler = PyOS_setsig(SIGINT, sHandleSigint); // Create an NSFileHandle for standard input NSFileHandle *stdinHandle = [NSFileHandle fileHandleWithStandardInput]; @@ -128,7 +135,7 @@ static int wait_for_stdin(void) { id notificationID = [[NSNotificationCenter defaultCenter] addObserverForName: NSFileHandleDataAvailableNotification object: stdinHandle queue: [NSOperationQueue mainQueue] // Use the main queue - usingBlock: ^(NSNotification *notification) {stopWithEvent();} + usingBlock: ^(NSNotification *notification) {sStopWithEvent();} ]; // Wait in the background for anything that happens to stdin @@ -142,7 +149,7 @@ static int wait_for_stdin(void) { // Restore the original SIGINT handler upon exiting the function - PyOS_setsig(SIGINT, originalSigintAction); + PyOS_setsig(SIGINT, originalSigintHandler); return 1; @@ -218,9 +225,18 @@ static int wait_for_stdin(void) { return NULL; } - [self->object updateLayerContentsWithBuffer: buffer - deviceWidth: (size_t)shape[1] - deviceHeight: (size_t)shape[0]]; + CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)buffer); + + if (!provider) { + PyErr_SetString(PyExc_RuntimeError, "CGDataProviderCreateWithCFData() failed"); + return NULL; + } + + [self->object updateLayerContentsWithDataProvider: provider + deviceWidth: (size_t)shape[1] + deviceHeight: (size_t)shape[0]]; + + CGDataProviderRelease(provider); END_OBJC_ENTRY RETURN_NULL_OR_NONE; @@ -355,7 +371,7 @@ static int wait_for_stdin(void) { static PyTypeObject FigureCanvasType = { PyVarObject_HEAD_INIT(NULL, 0) - .tp_name = "matplotlib.backends._macosx.FigureCanvas", + .tp_name = "matplotlib.backends._macos.FigureCanvas", .tp_doc = PyDoc_STR("A FigureCanvas object wraps a Cocoa NSView object."), .tp_basicsize = sizeof(FigureCanvas), .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, @@ -437,7 +453,7 @@ static int wait_for_stdin(void) { { BEGIN_OBJC_ENTRY PyObject *figureCanvasPyObject; - if (!PyArg_ParseTuple(args, "O", &figureCanvasPyObject)) { + if (!PyArg_ParseTuple(args, "O!", &FigureCanvasType, &figureCanvasPyObject)) { return -1; } @@ -446,13 +462,13 @@ static int wait_for_stdin(void) { self->object = [[MPLFigureManager alloc] initWithFigureCanvas:figureCanvas]; [self->object setPyObject:(PyObject *)self]; - if (!FigureManagerHashTable) { - FigureManagerHashTable = [NSHashTable weakObjectsHashTable]; + if (!sFigureManagerHashTable) { + sFigureManagerHashTable = [NSHashTable weakObjectsHashTable]; } - [FigureManagerHashTable addObject:self->object]; + [sFigureManagerHashTable addObject:self->object]; END_OBJC_ENTRY - return 0; + return PyErr_Occurred() ? -1 : 0; } static PyObject * @@ -484,13 +500,13 @@ static int wait_for_stdin(void) { FigureManager__close_and_clear_window_impl(FigureManager *self) { if (self->object) { - [FigureManagerHashTable removeObject:self->object]; + [sFigureManagerHashTable removeObject:self->object]; - [self->object close]; [self->object setPyObject:NULL]; + [self->object close]; self->object = nil; - if ([FigureManagerHashTable count] == 0 && IsRunningFromShow) { + if ([sFigureManagerHashTable count] == 0 && sIsRunningFromShow) { [NSApp stop:nil]; } } @@ -537,7 +553,8 @@ static int wait_for_stdin(void) { PyObject *args, PyObject *kwds) { BEGIN_OBJC_ENTRY - [self->object setWindowTitle:MPLGetStringWithPySequence(args)]; + NSString *title = MPLGetStringWithPySequence(args); + if (title) [self->object setWindowTitle:title]; END_OBJC_ENTRY RETURN_NULL_OR_NONE } @@ -578,7 +595,7 @@ static int wait_for_stdin(void) { static PyTypeObject FigureManagerType = { PyVarObject_HEAD_INIT(NULL, 0) - .tp_name = "matplotlib.backends._macosx.FigureManager", + .tp_name = "matplotlib.backends._macos.FigureManager", .tp_doc = PyDoc_STR("A FigureManager object wraps a " "MPLFigureManager Objective-C object."), .tp_basicsize = sizeof(FigureManager), @@ -642,13 +659,13 @@ static int wait_for_stdin(void) { { BEGIN_OBJC_ENTRY - FigureCanvas *canvas; - - if (!PyArg_ParseTuple(args, "O!", &FigureCanvasType, &canvas)) { + PyObject *figureCanvasPyObject; + if (!PyArg_ParseTuple(args, "O!", &FigureCanvasType, &figureCanvasPyObject)) { return -1; } - MPLFigureCanvas *figureCanvas = canvas->object; + MPLFigureCanvas *figureCanvas = ((FigureCanvas *)figureCanvasPyObject)->object; + if (!figureCanvas) { PyErr_SetString(PyExc_RuntimeError, "MPLFigureCanvas is NULL"); return -1; @@ -666,7 +683,7 @@ static int wait_for_stdin(void) { [[figureCanvas manager] installToolbar:toolbar]; END_OBJC_ENTRY - return 0; + return PyErr_Occurred() ? -1 : 0; } static void @@ -692,7 +709,12 @@ static int wait_for_stdin(void) { BEGIN_OBJC_ENTRY MPLStringArray *strings = MPLGetStringArrayWithPySequence(args); - if ([strings count] != 4) return NULL; + if (!strings) return NULL; + + if ([strings count] != 4) { + PyErr_SetString(PyExc_RuntimeError, "Invalid arguments to add_item"); + return NULL; + } [self->object addItemWithTitle: [strings objectAtIndex:0] tooltip: [strings objectAtIndex:1] @@ -739,14 +761,15 @@ static int wait_for_stdin(void) { NavigationToolbar2_set_message(NavigationToolbar2 *self, PyObject *args) { BEGIN_OBJC_ENTRY - [self->object updateMessage:MPLGetStringWithPySequence(args)]; + NSString *message = MPLGetStringWithPySequence(args); + if (message) [self->object updateMessage:message]; END_OBJC_ENTRY RETURN_NULL_OR_NONE } static PyTypeObject NavigationToolbar2Type = { PyVarObject_HEAD_INIT(NULL, 0) - .tp_name = "matplotlib.backends._macosx.NavigationToolbar2", + .tp_name = "matplotlib.backends._macos.NavigationToolbar2", .tp_doc = PyDoc_STR("NavigationToolbar2"), .tp_basicsize = sizeof(NavigationToolbar2), .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, @@ -882,7 +905,7 @@ static int wait_for_stdin(void) { static PyTypeObject TimerType = { PyVarObject_HEAD_INIT(NULL, 0) - .tp_name = "matplotlib.backends._macosx.Timer", + .tp_name = "matplotlib.backends._macos.Timer", .tp_doc = PyDoc_STR("A Timer object that contains an NSTimer that gets added to " "the event loop when started."), .tp_basicsize = sizeof(Timer), @@ -906,17 +929,15 @@ static int wait_for_stdin(void) { #pragma mark - Module -static bool backend_inited = false; - static PyObject * -_init(PyObject *unused, PyObject *args) +_macos__init(PyObject *unused, PyObject *args) { BEGIN_OBJC_ENTRY - PyObject *imagesDict; - if (!PyArg_ParseTuple(args, "O!", &PyDict_Type, &imagesDict)) { return NULL; } + PyObject *imagesPyDict; + if (!PyArg_ParseTuple(args, "O!", &PyDict_Type, &imagesPyDict)) { return NULL; } - NSDictionary *imagesDictionary = MPLGetStringDictionaryWithPyDict(imagesDict); + NSDictionary *imagesDictionary = MPLGetStringDictionaryWithPyDict(imagesPyDict); if (!imagesDictionary) { return NULL; } static dispatch_once_t onceToken; @@ -926,15 +947,15 @@ static int wait_for_stdin(void) { } if (![NSApp delegate]) { - appDelegate = [[MPLAppDelegate alloc] initWithImageDictionary:imagesDictionary]; - [NSApp setDelegate:appDelegate]; + sAppDelegate = [[MPLAppDelegate alloc] initWithImageDictionary:imagesDictionary]; + [NSApp setDelegate:sAppDelegate]; } - backend_inited = true; + sIsInitialized = YES; // Run our own event loop while waiting for stdin on the Python side // this is needed to keep the application responsive while waiting for input - PyOS_InputHook = wait_for_stdin; + PyOS_InputHook = sInputHook; }); END_OBJC_ENTRY @@ -942,65 +963,58 @@ static int wait_for_stdin(void) { } static PyObject * -event_loop_is_running(PyObject *self) +_macos_is_initialized(PyObject *self) { - BEGIN_OBJC_ENTRY - - if (backend_inited) { + if (sIsInitialized) { Py_RETURN_TRUE; } else { Py_RETURN_FALSE; } - - END_OBJC_ENTRY - RETURN_NULL_OR_NONE } -static PyObject * -wake_on_fd_write(PyObject *unused, PyObject *args) + +static PyObject* +_macos_wake_on_fd_write(PyObject* unused, PyObject* args) { BEGIN_OBJC_ENTRY int fd; if (!PyArg_ParseTuple(args, "i", &fd)) { return NULL; } - - dispatch_source_t source = dispatch_source_create( - DISPATCH_SOURCE_TYPE_READ, fd, 0, - dispatch_get_main_queue() - ); - - dispatch_source_set_event_handler(source, ^{ - PyGILState_STATE gstate = PyGILState_Ensure(); - PyErr_CheckSignals(); - PyGILState_Release(gstate); - - dispatch_source_cancel(source); - }); - - dispatch_resume(source); - + NSFileHandle* fh = [[NSFileHandle alloc] initWithFileDescriptor: fd]; + __block id notificationID = [[NSNotificationCenter defaultCenter] + addObserverForName: NSFileHandleDataAvailableNotification + object: fh + queue: nil + usingBlock: ^(NSNotification* note) { + NSFileHandle* strongFileHandle __attribute__((unused)) = fh; + PyGILState_STATE gstate = PyGILState_Ensure(); + PyErr_CheckSignals(); + PyGILState_Release(gstate); + [[NSNotificationCenter defaultCenter] removeObserver:notificationID]; + }]; + [fh waitForDataInBackgroundAndNotify]; END_OBJC_ENTRY RETURN_NULL_OR_NONE } static PyObject * -stop(PyObject *self, PyObject *unused) +_macos_stop(PyObject *self, PyObject *unused) { BEGIN_OBJC_ENTRY - stopWithEvent(); + sStopWithEvent(); END_OBJC_ENTRY RETURN_NULL_OR_NONE } static PyObject * -show(PyObject *self) +_macos_show(PyObject *self) { BEGIN_OBJC_ENTRY - // Iterating over FigureManagerHashTable will add the managers to the topmost + // Iterating over sFigureManagerHashTable will add the managers to the topmost // autorelease pool, wrap in @autoreleasepool as -[NSApp run] is long-running. @autoreleasepool { [NSApp activateIgnoringOtherApps: YES]; - for (MPLFigureManager *manager in [FigureManagerHashTable allObjects]) { + for (MPLFigureManager *manager in [sFigureManagerHashTable allObjects]) { [manager raise]; } } @@ -1011,9 +1025,9 @@ static int wait_for_stdin(void) { } Py_BEGIN_ALLOW_THREADS - IsRunningFromShow = YES; + sIsRunningFromShow = YES; [NSApp run]; - IsRunningFromShow = NO; + sIsRunningFromShow = NO; Py_END_ALLOW_THREADS END_OBJC_ENTRY @@ -1021,7 +1035,7 @@ static int wait_for_stdin(void) { } static PyObject * -choose_save_file(PyObject *unused, PyObject *args) +_macos_choose_save_file(PyObject *unused, PyObject *args) { BEGIN_OBJC_ENTRY @@ -1058,7 +1072,7 @@ static int wait_for_stdin(void) { static int -ModuleExec(PyObject *m) +_macos_mod_exec(PyObject *m) { static BOOL sLoaded = NO; @@ -1087,13 +1101,13 @@ static int wait_for_stdin(void) { return 0; } -static struct PyModuleDef moduledef = { +static struct PyModuleDef _macos_moduledef = { .m_base = PyModuleDef_HEAD_INIT, .m_name = "_macos", .m_doc = PyDoc_STR("macOS native backend"), .m_size = 0, .m_slots = (PyModuleDef_Slot[]){ - {Py_mod_exec, ModuleExec}, + {Py_mod_exec, _macos_mod_exec}, {Py_mod_multiple_interpreters, Py_MOD_MULTIPLE_INTERPRETERS_NOT_SUPPORTED}, #ifdef Py_GIL_DISABLED {Py_mod_gil, Py_MOD_GIL_NOT_USED}, @@ -1102,28 +1116,28 @@ static int wait_for_stdin(void) { }, .m_methods = (PyMethodDef[]){ {"_init", - (PyCFunction)_init, + (PyCFunction)_macos__init, METH_VARARGS, PyDoc_STR( "Perform a one-time initialization of the backend. Sets up the NSApp delegate" "if one is not already present.")}, - {"event_loop_is_running", - (PyCFunction)event_loop_is_running, + {"is_initialized", + (PyCFunction)_macos_is_initialized, METH_NOARGS, PyDoc_STR( - "Return whether the macosx backend has set up the NSApp main event loop.")}, + "Return whether _init() has been called .")}, {"wake_on_fd_write", - (PyCFunction)wake_on_fd_write, + (PyCFunction)_macos_wake_on_fd_write, METH_VARARGS, PyDoc_STR( "Arrange for Python to invoke its signal handlers when (any) data is\n" "written on the file descriptor given as argument.")}, {"stop", - (PyCFunction)stop, + (PyCFunction)_macos_stop, METH_VARARGS, PyDoc_STR("Stop the NSApp.")}, {"show", - (PyCFunction)show, + (PyCFunction)_macos_show, METH_NOARGS, PyDoc_STR( "Show all the figures and enter the main loop.\n" @@ -1131,7 +1145,7 @@ static int wait_for_stdin(void) { "This function does not return until all Matplotlib windows are closed,\n" "and is normally not needed in interactive sessions.")}, {"choose_save_file", - (PyCFunction)choose_save_file, + (PyCFunction)_macos_choose_save_file, METH_VARARGS, PyDoc_STR("Query the user for a location where to save a file.")}, {} /* Sentinel */ @@ -1143,7 +1157,7 @@ static int wait_for_stdin(void) { PyMODINIT_FUNC PyInit__macos(void) { - return PyModuleDef_Init(&moduledef); + return PyModuleDef_Init(&_macos_moduledef); } #pragma GCC visibility pop From 33bef74b37a0d858d8b8c31ccce5a68eb4517792 Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Sat, 8 Aug 2026 11:06:08 -0700 Subject: [PATCH 14/20] Fixing pre-commit failures --- lib/matplotlib/backends/backend_macos.py | 4 ++-- src/macos/MPLFigureCanvas.m | 8 ++++---- src/macos/MPLUtils.m | 3 +-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/matplotlib/backends/backend_macos.py b/lib/matplotlib/backends/backend_macos.py index c4482577239b..bad1a74abb1a 100644 --- a/lib/matplotlib/backends/backend_macos.py +++ b/lib/matplotlib/backends/backend_macos.py @@ -116,13 +116,13 @@ def _handle_mouse_exited(self, x, y, modifiers): modifiers=self._mpl_modifiers(modifiers))._process() def _handle_mouse_down(self, x, y, button, modifiers, dblclick): - button=self._mpl_button(button) + button = self._mpl_button(button) if button is not None: 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): - button=self._mpl_button(button) + button = self._mpl_button(button) if button is not None: MouseEvent("button_release_event", self, x, y, button, modifiers=self._mpl_modifiers(modifiers))._process() diff --git a/src/macos/MPLFigureCanvas.m b/src/macos/MPLFigureCanvas.m index aae49b026a89..26f2db78cbe9 100644 --- a/src/macos/MPLFigureCanvas.m +++ b/src/macos/MPLFigureCanvas.m @@ -31,11 +31,11 @@ - (instancetype) initWithFrame:(NSRect)rect [self setLayer:canvasLayer]; [self setWantsLayer:YES]; - + CALayer *rubberbandLayer = [CALayer layer]; [rubberbandLayer setDelegate:self]; [rubberbandLayer setNeedsDisplayOnBoundsChange:YES]; - + _canvasLayer = canvasLayer; _rubberbandLayer = rubberbandLayer; @@ -183,7 +183,7 @@ - (void) _displayRubberbandLayer CGImageRef contents = MPLCreateImage(bounds.size, contentsScale, ^(CGContextRef context) { CGRect strokeRect = CGRectInset(bounds, 0.5, 0.5); CGFloat dashPattern[2] = { 3.0, 3.0 }; - + CGContextSetGrayStrokeColor(context, 1.0, 1.0); CGContextSetLineDash(context, 0.0, dashPattern, 2); CGContextStrokeRect(context, strokeRect); @@ -204,7 +204,7 @@ - (void) _displayRubberbandLayer - (NSString *) _mappedStringWithCharacters:(NSString *)characters { static NSDictionary *sKeyMap = nil; - + if (!sKeyMap) sKeyMap = @{ @( NSLeftArrowFunctionKey ): @"left", @( NSRightArrowFunctionKey ): @"right", @( NSUpArrowFunctionKey ): @"up", @( NSDownArrowFunctionKey ): @"down", diff --git a/src/macos/MPLUtils.m b/src/macos/MPLUtils.m index b9ce46846e43..9a1bac0aa6df 100644 --- a/src/macos/MPLUtils.m +++ b/src/macos/MPLUtils.m @@ -93,7 +93,7 @@ void MPLCallMethod(PyObject *pyObject, const char *name, char const *format, ... // PyUnicode_AsUTF8AndSize() should set error in this case return nil; } - + // This should never happen, but we are about to cast from signed to unsigned if (size < 0) { PyErr_SetString(PyExc_RuntimeError, "Size is less than 0"); @@ -284,4 +284,3 @@ CGImageRef MPLCreateImage(CGSize size, CGFloat scale, void (^callback)(CGContext CGBitmapInfo bitmapInfo = 0 | kCGImageAlphaPremultipliedFirst | kCGImageByteOrder32Little; return sCreateImage(size, scale, YES, kCGColorSpaceSRGB, 4, bitmapInfo, callback); } - From 7bd0e0cb649f942252265ebe05dd574073739154 Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Sun, 9 Aug 2026 10:48:37 -0700 Subject: [PATCH 15/20] Limit Python.h usage --- src/macos/MPLFigureCanvas.h | 4 ++-- src/macos/MPLFigureManager.h | 3 +-- src/macos/MPLNavigationToolbar2.h | 4 ++-- src/macos/MPLUtils.h | 20 +++++++++++++------- src/macos/MPLUtils.m | 17 +++++++++++------ src/macos/_macos.m | 5 +++-- 6 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/macos/MPLFigureCanvas.h b/src/macos/MPLFigureCanvas.h index 27009da3516b..6ee00e575420 100644 --- a/src/macos/MPLFigureCanvas.h +++ b/src/macos/MPLFigureCanvas.h @@ -1,5 +1,5 @@ #import -#import +#import "MPLUtils.h" NS_ASSUME_NONNULL_BEGIN @@ -18,7 +18,7 @@ NS_ASSUME_NONNULL_BEGIN - (void) removeRubberband; - (void) requestDisplayLayerWithNeedsDraw:(BOOL)needsDraw; // Thread-safe -@property (nonatomic, assign, nullable) PyObject *pyObject; +@property (nonatomic, assign, nullable) MPLPyObjectRef pyObject; @property (nonatomic, weak, nullable) MPLFigureManager *manager; diff --git a/src/macos/MPLFigureManager.h b/src/macos/MPLFigureManager.h index 0acc441a787a..4d2ab9aa7471 100644 --- a/src/macos/MPLFigureManager.h +++ b/src/macos/MPLFigureManager.h @@ -1,5 +1,4 @@ #import -#import #import "MPLUtils.h" NS_ASSUME_NONNULL_BEGIN @@ -21,7 +20,7 @@ NS_ASSUME_NONNULL_BEGIN - (void) updateWindowMode:(nullable NSString *)windowMode; - (void) installToolbar:(MPLNavigationToolbar2 *)toolbar; -@property (nonatomic, assign, nullable) PyObject *pyObject; +@property (nonatomic, assign, nullable) MPLPyObjectRef pyObject; @property (nonatomic) NSString *windowTitle; diff --git a/src/macos/MPLNavigationToolbar2.h b/src/macos/MPLNavigationToolbar2.h index 1a90b6a05684..7673e1f15407 100644 --- a/src/macos/MPLNavigationToolbar2.h +++ b/src/macos/MPLNavigationToolbar2.h @@ -1,5 +1,5 @@ #import -#import +#import "MPLUtils.h" NS_ASSUME_NONNULL_BEGIN @@ -10,7 +10,7 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype) initWithFrame:(NSRect)frame NS_UNAVAILABLE; - (nullable instancetype) initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; -@property (nonatomic, assign, nullable) PyObject *pyObject; +@property (nonatomic, assign, nullable) MPLPyObjectRef pyObject; - (void) addItemWithTitle: (NSString *) title tooltip: (NSString *) tooltip diff --git a/src/macos/MPLUtils.h b/src/macos/MPLUtils.h index ac9cb386c12e..701fb1f62df0 100644 --- a/src/macos/MPLUtils.h +++ b/src/macos/MPLUtils.h @@ -1,7 +1,6 @@ #import #import #import -#import #import NS_ASSUME_NONNULL_BEGIN @@ -9,6 +8,13 @@ NS_ASSUME_NONNULL_BEGIN typedef NSArray MPLStringArray; typedef NSDictionary MPLStringDictionary; +/* + We declare our own type for (PyObject *) so the Objective-C classes + do not have to pull in . We limit usage of the Python C API + to _macos.m and MPLUtils. +*/ +typedef void *MPLPyObjectRef; + /* When a method or function is NS_UNAVAILABLE, call MPLUnavailable() @@ -33,7 +39,7 @@ extern os_log_t MPLGetLogger(void); discard the result, print any exception. */ extern void MPLCallMethod( - PyObject * _Nullable pyObject, + MPLPyObjectRef pyObject, const char *name, char const * _Nullable format, ... ); @@ -43,7 +49,7 @@ extern void MPLCallMethod( Converts a Python str into an NSString. Returns nil and raises a Python exception if the str could not be converted. */ -extern NSString * _Nullable MPLGetStringWithPyString(PyObject * _Nullable string); +extern NSString * _Nullable MPLGetStringWithPyString(MPLPyObjectRef _Nullable string); /* @@ -51,7 +57,7 @@ extern NSString * _Nullable MPLGetStringWithPyString(PyObject * _Nullable string Returns nil and raises a Python exception if the sequence is not exactly one string or if the string could not be converted into an NSString. */ -extern NSString * _Nullable MPLGetStringWithPySequence(PyObject * _Nullable pySequence); +extern NSString * _Nullable MPLGetStringWithPySequence(MPLPyObjectRef _Nullable pySequence); /* @@ -60,7 +66,7 @@ extern NSString * _Nullable MPLGetStringWithPySequence(PyObject * _Nullable pySe any item is not a string, or any item could not be converted into an NSString. */ extern MPLStringArray * _Nullable MPLGetStringArrayWithPySequence( - PyObject * _Nullable pySequence + MPLPyObjectRef _Nullable pySequence ); /* @@ -69,7 +75,7 @@ extern MPLStringArray * _Nullable MPLGetStringArrayWithPySequence( key/value was not a str, or any str could not be converted into an NSString. */ extern MPLStringDictionary * _Nullable MPLGetStringDictionaryWithPyDict( - PyObject * _Nullable dict + MPLPyObjectRef _Nullable dict ); @@ -83,7 +89,7 @@ extern MPLStringDictionary * _Nullable MPLGetStringDictionaryWithPyDict( 4) expectedDimensions is non-0 and not equal to buffer->ndim */ extern NSData * _Nullable MPLGetBufferWithPyObject( - PyObject * _Nullable pyObject, + MPLPyObjectRef _Nullable pyObject, size_t expectedDimensions, ssize_t * _Nullable outShape ); diff --git a/src/macos/MPLUtils.m b/src/macos/MPLUtils.m index 9a1bac0aa6df..2d0e1276ad0b 100644 --- a/src/macos/MPLUtils.m +++ b/src/macos/MPLUtils.m @@ -1,5 +1,8 @@ #import "MPLUtils.h" +#define PY_SSIZE_T_CLEAN +#import "Python.h" + void _MPLUnavailable(const char *s) { @@ -25,7 +28,7 @@ os_log_t MPLGetLogger(void) #pragma mark - Python Utility Functions -void MPLCallMethod(PyObject *pyObject, const char *name, char const *format, ...) +void MPLCallMethod(MPLPyObjectRef pyObject, const char *name, char const *format, ...) { // It is possible for Obj-C objects to momentarily outlive their paired Python // counterparts, especially when dealing with AppKit objects. Hence, allow @@ -74,7 +77,7 @@ void MPLCallMethod(PyObject *pyObject, const char *name, char const *format, ... } -NSString *MPLGetStringWithPyString(PyObject *pyString) +NSString *MPLGetStringWithPyString(MPLPyObjectRef pyString) { if (!pyString) { if (!PyErr_Occurred()) PyErr_SetString(PyExc_RuntimeError, "Input is NULL"); @@ -111,7 +114,7 @@ void MPLCallMethod(PyObject *pyObject, const char *name, char const *format, ... } -MPLStringArray *MPLGetStringArrayWithPySequence(PyObject *pySequence) +MPLStringArray *MPLGetStringArrayWithPySequence(MPLPyObjectRef pySequence) { if (!pySequence) { if (!PyErr_Occurred()) PyErr_SetString(PyExc_RuntimeError, "Input is NULL"); @@ -147,7 +150,7 @@ void MPLCallMethod(PyObject *pyObject, const char *name, char const *format, ... } -NSString *MPLGetStringWithPySequence(PyObject *pySequence) +NSString *MPLGetStringWithPySequence(MPLPyObjectRef _Nullable pySequence) { MPLStringArray *array = MPLGetStringArrayWithPySequence(pySequence); @@ -160,7 +163,7 @@ void MPLCallMethod(PyObject *pyObject, const char *name, char const *format, ... } -MPLStringDictionary *MPLGetStringDictionaryWithPyDict(PyObject *dict) +MPLStringDictionary *MPLGetStringDictionaryWithPyDict(MPLPyObjectRef dict) { if (!dict) { if (!PyErr_Occurred()) PyErr_SetString(PyExc_RuntimeError, "Input is NULL"); @@ -190,7 +193,7 @@ void MPLCallMethod(PyObject *pyObject, const char *name, char const *format, ... NSData * _Nullable MPLGetBufferWithPyObject( - PyObject * _Nullable pyObject, + MPLPyObjectRef _Nullable pyObject, size_t expectedDimensions, ssize_t * _Nullable outShape ) { @@ -235,6 +238,8 @@ void MPLCallMethod(PyObject *pyObject, const char *name, char const *format, ... } +#pragma mark - + CGImageRef sCreateImage( CGSize size, CGFloat scale, BOOL flipped, CFStringRef colorSpaceName, size_t componentCount, CGBitmapInfo bitmapInfo, diff --git a/src/macos/_macos.m b/src/macos/_macos.m index d776d9773792..8bbf6af04b62 100755 --- a/src/macos/_macos.m +++ b/src/macos/_macos.m @@ -1,13 +1,14 @@ -#define PY_SSIZE_T_CLEAN #import #import -#import #import "MPLUtils.h" #import "MPLAppDelegate.h" #import "MPLFigureCanvas.h" #import "MPLFigureManager.h" #import "MPLNavigationToolbar2.h" +#define PY_SSIZE_T_CLEAN +#import + #if !__has_feature(objc_arc_fields) #error "The macOS backend requires ARC C struct fields support (objc_arc_fields)." #endif From 40048c6ee7e12be81d7b77a7e5737bd3375c424e Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Sun, 9 Aug 2026 10:57:39 -0700 Subject: [PATCH 16/20] Adding placeholder files --- src/macos/MPLEventLoop.h | 4 ++++ src/macos/MPLEventLoop.m | 4 ++++ src/macos/MPLSubplotTool.h | 4 ++++ src/macos/MPLSubplotTool.m | 4 ++++ src/macos/MPLTimer.h | 4 ++++ src/macos/MPLTimer.m | 4 ++++ 6 files changed, 24 insertions(+) create mode 100644 src/macos/MPLEventLoop.h create mode 100644 src/macos/MPLEventLoop.m create mode 100644 src/macos/MPLSubplotTool.h create mode 100644 src/macos/MPLSubplotTool.m create mode 100644 src/macos/MPLTimer.h create mode 100644 src/macos/MPLTimer.m diff --git a/src/macos/MPLEventLoop.h b/src/macos/MPLEventLoop.h new file mode 100644 index 000000000000..4be85e447b41 --- /dev/null +++ b/src/macos/MPLEventLoop.h @@ -0,0 +1,4 @@ +/* + This file is a placeholder and will be implemented in + the Event Loop PR +*/ diff --git a/src/macos/MPLEventLoop.m b/src/macos/MPLEventLoop.m new file mode 100644 index 000000000000..4be85e447b41 --- /dev/null +++ b/src/macos/MPLEventLoop.m @@ -0,0 +1,4 @@ +/* + This file is a placeholder and will be implemented in + the Event Loop PR +*/ diff --git a/src/macos/MPLSubplotTool.h b/src/macos/MPLSubplotTool.h new file mode 100644 index 000000000000..93bd3521540c --- /dev/null +++ b/src/macos/MPLSubplotTool.h @@ -0,0 +1,4 @@ +/* + This file is a placeholder and will be implemented in + the Subplot Tool PR +*/ diff --git a/src/macos/MPLSubplotTool.m b/src/macos/MPLSubplotTool.m new file mode 100644 index 000000000000..93bd3521540c --- /dev/null +++ b/src/macos/MPLSubplotTool.m @@ -0,0 +1,4 @@ +/* + This file is a placeholder and will be implemented in + the Subplot Tool PR +*/ diff --git a/src/macos/MPLTimer.h b/src/macos/MPLTimer.h new file mode 100644 index 000000000000..04fcade1436c --- /dev/null +++ b/src/macos/MPLTimer.h @@ -0,0 +1,4 @@ +/* + This file is a placeholder and will be implemented in + the Timer PR +*/ diff --git a/src/macos/MPLTimer.m b/src/macos/MPLTimer.m new file mode 100644 index 000000000000..04fcade1436c --- /dev/null +++ b/src/macos/MPLTimer.m @@ -0,0 +1,4 @@ +/* + This file is a placeholder and will be implemented in + the Timer PR +*/ From c47c02bb9cd2261e94b87ac1de7d93ac390313c4 Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Sun, 9 Aug 2026 11:12:07 -0700 Subject: [PATCH 17/20] Updating section name --- src/macos/MPLUtils.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/macos/MPLUtils.m b/src/macos/MPLUtils.m index 2d0e1276ad0b..630929d5ed35 100644 --- a/src/macos/MPLUtils.m +++ b/src/macos/MPLUtils.m @@ -238,7 +238,7 @@ void MPLCallMethod(MPLPyObjectRef pyObject, const char *name, char const *format } -#pragma mark - +#pragma mark - Graphics Utility Functions CGImageRef sCreateImage( CGSize size, CGFloat scale, BOOL flipped, From 4c2b9120544cc1b7ab48a8a624faa409378113ac Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Sun, 6 Sep 2026 10:46:20 -0700 Subject: [PATCH 18/20] Add type parameter to new rcparams --- lib/matplotlib/rcsetup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 9116349f0476..c3e14ef5d120 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -3432,6 +3432,7 @@ class _Subsection: _Param( "macos.appearance", default="system", + type=Literal["system", "light", "dark"], validator=["system", "light", "dark"], description="Whether windows and controls use a light or dark appearance. " "'system' uses 'Appearance' from System Settings." @@ -3439,6 +3440,7 @@ class _Subsection: _Param( "macos.window_mode", default="system", + type=Literal["system", "tab", "window"], validator=["system", "tab", "window"], description="How to open new figures (system, tab, window). " "'system' uses 'Prefer tabs...' from System Settings." From d98e3459de8d28efcf7058d3610b3cfa4e4c43d1 Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Sun, 6 Sep 2026 11:51:30 -0700 Subject: [PATCH 19/20] Add _emulatedLeftMouseDownButton Co-authored-by: Mishanil Kazreen --- src/macos/MPLFigureCanvas.m | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/macos/MPLFigureCanvas.m b/src/macos/MPLFigureCanvas.m index 26f2db78cbe9..ebdd139a5876 100644 --- a/src/macos/MPLFigureCanvas.m +++ b/src/macos/MPLFigureCanvas.m @@ -9,6 +9,7 @@ @interface MPLFigureCanvas () @implementation MPLFigureCanvas { BOOL _isLeftMouseDown; BOOL _isHandCursorActive; + NSInteger _emulatedLeftMouseDownButton; NSEventModifierFlags _previousModifierFlags; CALayer *_canvasLayer; CALayer *_rubberbandLayer; @@ -343,11 +344,15 @@ - (void) _handleMouseDownOrUp:(NSEvent *)event isPress:(BOOL)isPress } _isLeftMouseDown = YES; + _emulatedLeftMouseDownButton = buttonNumber; [self _updateHandCursor]; } else if ([event type] == NSEventTypeLeftMouseUp) { - _isLeftMouseDown = NO; + if (_isLeftMouseDown) { + buttonNumber = _emulatedLeftMouseDownButton; + _isLeftMouseDown = NO; + } [self _updateHandCursor]; } From c664d322791ddd15a828886520652ab284e35032 Mon Sep 17 00:00:00 2001 From: Ricci Adams Date: Sun, 6 Sep 2026 11:51:37 -0700 Subject: [PATCH 20/20] Remove middle-click emulation --- src/macos/MPLFigureCanvas.m | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/macos/MPLFigureCanvas.m b/src/macos/MPLFigureCanvas.m index ebdd139a5876..030ec38c97a1 100644 --- a/src/macos/MPLFigureCanvas.m +++ b/src/macos/MPLFigureCanvas.m @@ -337,10 +337,6 @@ - (void) _handleMouseDownOrUp:(NSEvent *)event isPress:(BOOL)isPress if (modifierFlags & NSEventModifierFlagControl) { // emulate a right-button click buttonNumber = 1; - - } else if (modifierFlags & NSEventModifierFlagOption) { - // emulate a middle-button click - buttonNumber = 2; } _isLeftMouseDown = YES;