macOS backend rework, initial PR - #32161
Conversation
Obj-C classes: MatplotlibAppDelegate -> MPLLegacyAppDelegate Window -> MPLLegacyWindow View -> MPLLegacyView NavigationToolbar2Handler -> MPLLegacyNavigationToolbar2Handler
|
I think this is a nice direction to go, thank you for taking the time and effort to work on this major overhaul! Since it was so much content I had AI do a quick sweep of the code and read through all the comments and they all look valid and like things to look into. I'll paste it all below (2+), feel free to ignore if you don't want to read an AI review but it will take me a while to go through this all later so I wanted to leave you some initial comments for now. Overall 👍 on transitioning to this. 1. Framing With this we are trying to shim in the backwards compatibility, is there a reason we don't just leave the What this kind of reminds me of is the mplcairo backend (which I always wished would come into core at some point) and how we would name something like that if it did come into core because we already have the Cairo backends and would need some transition for those. 2. Grouping _macos.m is now 1149 lines and the largest file in the set, holding four unrelated PyTypeObjects. Given the whole premise was per-file responsibility:
Also, ~15 of those glue functions are near-identical BEGIN_OBJC_ENTRY / parse / one message send / RETURN_NULL_OR_NONE. A couple of macros would halve that. The Obj-C→Python boundary leaks in one place: MPLFigureCanvas.m:423 calls PyErr_SetString directly, which contradicts the design you documented. updateLayerContentsWithBuffer: should return BOOL and let _macos.m raise. 3. Correctness bugs Ranked roughly by severity. FigureCanvasMac.draw never runs. backend_macos.py:45 and :138: class FigureCanvasMac(_macos.FigureCanvas, FigureCanvasBase): MRO is FigureCanvasMacAgg → FigureCanvasAgg → FigureCanvasMac → …, so canvas.draw() resolves to FigureCanvasAgg.draw and your override is dead. Consequence: a user calling fig.canvas.draw() renders into Agg but never calls _request_display_layer, so the window shows stale content. (And if it were reached, its super().draw() would hit FigureCanvasBase.draw, a no-op — it would never render at all.) The legacy backend avoids this with a single class, Agg first: class FigureCanvasLegacyMac(FigureCanvasAgg, _macosx.FigureCanvas, FigureCanvasBase). Note test_backend_macosx.py's first test is exactly fig.canvas.draw() — it passes only because it runs against the legacy backend. Middle and right mouse buttons are swapped during motion. backend_macos.py:83-89: (MouseButton.MIDDLE, 1 << 1), +[NSEvent pressedMouseButtons] documents bit 1 as right and bit 2 as other/middle. The legacy code has it right (_legacymac.m:336-337), and your own _handleMouseDownOrUp: swaps correctly — so press/release and motion will disagree with each other. Separately, _handleMouseDownOrUp: maps AppKit buttons 3/4 to 4/5, but _mpl_buttons maps those same physical buttons to MouseButton.BACK/FORWARD (8/9). Back/forward buttons report different values on press than on motion. _needsDrawOnNextDisplayLayer is a latch, not a flag. MPLFigureCanvas.m:110-114 reads it but never clears it, and :486 overwrites rather than accumulates. Two bugs fall out:
Wants _needsDrawOnNextDisplayLayer |= needsDraw; on request, and clear it in displayLayer:. Window resize ignores the backing scale. MPLFigureManager.m:133: [window convertRectFromBacking:rect]; // return value discarded rect stays in device pixels, so manager.resize() makes the window 2× too large on Retina. Should be rect = [window convertRectFromBacking:rect];. Python method called on a zero-refcount object. _macos.m:483-497 — FigureManager__close_and_clear_window_impl calls [self->object close] before setPyObject:NULL. On the FigureManager_dealloc path (:500, reached whenever a figure is GC'd without plt.close()), -close synchronously fires windowWillClose: → MPLCallMethod(_pyObject, …) on an object already inside tp_dealloc. PyObject_GetAttrString takes it 0→1 and the release takes it back to 0, re-entering _Py_Dealloc. Fix is one line: clear pyObject before close. FigureCanvas_dealloc and NavigationToolbar2_dealloc already get this order right. windowShouldClose: has the wrong signature. MPLFigureManager.m:102 takes (NSNotification *); NSWindowDelegate declares (NSWindow *)sender. It works only because messaging is dynamic. self used before [super init…]. MPLFigureManager.m:51-64 calls [window setDelegate:self], makeFirstResponder:, and addSubview: before [super initWithWindow:window] on line 66. Move all the configuration after the super call. NULL returned without an exception set. _macos.m:694-695: MPLStringArray *strings = MPLGetStringArrayWithPySequence(args); If conversion succeeded but the count is wrong, this returns NULL with no error → SystemError. choose_save_file (:1029) gets this right. Missing type check. _macos.m:440-444 — PyArg_ParseTuple(args, "O", …) then an unchecked cast to FigureCanvas *. NavigationToolbar2_init in the same file uses "O!" with &FigureCanvasType; do the same here. (Carried over from legacy, but trivial to fix now.) tp_init swallows Obj-C exceptions. FigureManager_init (:455) and NavigationToolbar2_init (:669) return 0 unconditionally, so a caught Obj-C exception sets a Python error but init reports success. FigureCanvas_init:185 gets it right with return PyErr_Occurred() ? -1 : 0;. errSetException can crash. _macos.m:56 — [[exception reason] UTF8String] returns NULL when reason is nil, and PyErr_SetString(…, NULL) segfaults. Dispatch source outlives its fd. _macos.m:959-983 — this is a rewrite of the legacy NSFileHandle version, and it introduces two problems. The source is never stored or cancelled if SIGINT never arrives, and _allow_interrupt's finally closes rsock underneath a live DISPATCH_SOURCE_TYPE_READ — which Apple documents as a hard error (cancel first, close in the cancel handler). The handler block also strongly captures source, so a never-fired source leaks permanently. flagsChanged: press/release detection. MPLFigureCanvas.m:299 — currentFlags > _previousModifierFlags compares bitmask magnitudes. Release Command (bit 20) while pressing Shift (bit 17) in one event and it reports a release. Want changed = current ^ previous; isPress = (current & changed) != 0. Rubberband is a subview of a layer-hosted view. MPLFigureCanvas.m:176 adds MPLRubberbandView as a subview of the canvas, but :48-54 makes the canvas layer-hosting by assigning layer directly. Apple's layer-hosting docs (the same page the PR links) say a layer-hosting view must not have subviews. It'll probably render today; it's the kind of thing that breaks across macOS releases. A CAShapeLayer sublayer with lineDashPattern would be both supported and give you marching ants for free. layer.contentsScale is never set. viewDidChangeBackingProperties (:77) is exactly where you'd set it, and AppKit does not maintain it for layer-hosted views. kCAGravityResize currently papers over this; setting it gives you a guaranteed 1:1 pixel mapping. (Also worth calling super here.) kCGImageAlphaLast may defeat the whole point. MPLFigureCanvas.m:428 — non-premultiplied alpha typically forces Core Animation into a CPU conversion, which undercuts the sRGB/GPU-compositing work in MPLFigureManager. Since the layer is opaque:YES over white, kCGImageAlphaNoneSkipLast is probably what you want; worth measuring both. [NSApp stop:] without a wake event. _macos.m:494 — stop: only takes effect once the next event is processed. You have stopWithEvent() for precisely this; using bare stop: here means closing the last window may not exit show() until unrelated input arrives. 4. Performance The keymap dictionary is rebuilt on every keystroke. MPLFigureCanvas.m:189-210 allocates a 35-entry NSDictionary plus 35 NSNumbers per key event. Wants static + dispatch_once. The cursor dictionary is worse. :456-462 allocates a dict, 5 NSNumbers, and calls all five [NSCursor …Cursor] accessors on every cursor change — which during pan/zoom is every mouse-move. A switch is both faster and clearer. MPLGetStringWithPySequence on the hot path. set_message (_macos.m:742) fires on every motion event, and that helper (MPLUtils.m:140) builds an NSMutableArray, an NSString, and a [copy] just to pull one string out of a 1-tuple. PyArg_ParseTuple(args, "s", &cstr) is one allocation instead of three. Same pattern in set_window_title, _set_window_appearance, _set_window_mode, add_item. choose_save_file holds the GIL across a modal panel. _macos.m:1044 — [panel runModal] blocks every other Python thread for as long as the user browses. Wrap in Py_BEGIN_ALLOW_THREADS. (Also __block on modalResponse at :1043 is vestigial, and :1050 returns 0 instead of NULL.) _buttonWithCallbackName: is O(n) over two parallel arrays. MPLNavigationToolbar2.m:13-14, 96-112 — _buttons and _callbackNames kept index-synchronized by convention is fragile. NSView already has an identifier property: store the callback name there, key a dictionary on it, and both arrays disappear along with the indexOfObject: scans. 5. Idiom notes
6. Free-threading, build, testing Py_MOD_GIL_NOT_USED (_macos.m:1099) is a claim I don't think holds. FigureManagerHashTable, IsRunningFromShow, backend_inited, appDelegate, and every self->object are unsynchronized. FigureManager_new requires the main thread, but FigureCanvas and Timer don't. Either drop the declaration for now or document that everything except FigureCanvas is main-thread-only. Build: override_options: ['werror=true','optimization=s'] hardcodes -Os, so a --buildtype=debug build of matplotlib still can't get a debuggable macOS backend. Worth making that conditional. werror=true has precedent from _macosx, but pairing it with ~35 new warning flags meaningfully raises the odds that a future compiler breaks downstream packagers' builds. Also check NSBezelStyleSmallSquare (MPLNavigationToolbar2.m:129) — I believe that spelling only exists in the macOS 14+ SDK; worth confirming against your minimum supported Xcode, since deployment target is 10.14. |
|
Thank you for the reply! I'll check more of this tomorrow, but I wanted to reply to a few points: 1. FramingI think that the eventual plan is to have a period of time where both 2. GroupingI thought about placing the Python glue code in the .m files. My worry is:
(wait_for_stdin / flushEvents / stopWithEvent / handleSigint) are the old implementation for now because I specifically want the PR to serve as a documentation for MPLEventLoop. 3. Correctness bugs4. Performance5. Idiom notesI need to go through each of these points tomorrow and check. I think there are some valid issues here and some cases where the AI is using some out-of-date information on performance characteristics ;)
I'd like your opinion on #31968 at some point (no rush!). My inclination is to go ahead and add a main thread check to the BEGIN/END macros except for draw_idle(). I feel like the advantages of preventing a crash and alerting the user with a friendlier message outweight the 2-3 lines of code in the backend.
I'm not sure if I follow the AI's logic here. None of the other backends have debug builds, and I would think that we would see future compiler breaks before downstream packagers'.
Should be in 10.14, but it's a moot point - it goes away with the Toolbar PR so we don't overlap the Tahoe window corner. |
|
That was a great read! While there were cases of the AI being pedantic, it brought up some good issues. I'll hide the details in disclosures so we can focus on the important bits below. Fixed AI SuggestionsList of fixed suggestions
I changed the rubberband code to be a CALayer instead of a NSView so a future AI doesn't flag this as a concern. A subview of a layer-hosted view should be fine per an AppKit engineer; however, that was 14 years old and Apple could always break it accidentally. Ignored AI SuggestionsList of Ignored Suggestions
|
|
Two changes: I decided to take the AI's suggestion of limiting I'm adding placeholder files for MPLEventLoop, MPLSubplotTool, and MPLTimer. Xcode does not handle missing files gracefully – I'm routinely having to add/remove them as I switch among the different PR branches and it's slowing me down. |
Are you able to try the new Stacked Pull Request feature? That seems ideal for this kind of work here where you need to build off of this branch and then you can keep working from this without needing to try to get everything into main. |
Unfortunately, "Stacked pull requests require all branches to be in the same repository. Cross-fork stacks are not supported", which makes it useless for our purposes. I was really excited for Stacked PRs and was disappointed when I saw that sentence :( |
|
I’m not following the details of this work, but would it help if we made a feature branch in this repo for @iccir to target? |
I think that was mentioned briefly at the meeting but we didn't want to change process too much. I'm willing to do whatever will help make the review process easier, however! |
PR summary
As discussed in Thursday's meeting, this pull request starts the process of merging the reworked macOS backend into main. The
macosxbackend is still the default for now, the reworked backend can be tested with:export MPLBACKEND=macosCloses #31770
Closes #31813
Closes #31875
Closes #31933
File Structure
Previously, the Objective-C layer used a single "_macosx.m" file with several
@interfaceand@implementationblocks. This file also contained all the Python/Obj-C glue code as well as thePyTypeObjectdeclarations.This PR splits that file into several files with distinct responsibilities:
• Python/Obj-C glue code
•
PyTypeObjectdeclarations• Backing
PyTypeObjectC structs[hm]ClassName.[hm](most non-glue code should live here)
[hm]Why?
Objective-C is typically organized in .h/.m file pairs with one public class per file. This is how Objective-C enforces encapsulation. There are no
privateorprotectedkeywords like in other languages (there was a@privatekeyword for instance variables, but this fell out of usage when ivars were moved to the .m file circa-2007).A private method is never declared in the header file, it only shows up in the .m file. A protected method typically appears in the header file as a category called "SubclassesToOverride" / "ProtectedMethods" / etc. Some projects will use a "MyClass_Internal.h" or "MyClass_Private.h" file with a category for the concept of
packagemethods.Additionally, compiler flags operate at a per-compile-unit basis. Had the file already been split, the migration to ARC could have taken place in segments rather than all at once. Typically, an Objective-C project will compile most sources with
-Osand have one or two "Fast" files with-O3,-ffast-math, etc.Paired Classes
As discussed in my ARC PR, I believe that an ideal architecture involves pairing each Python backend class with a corresponding Objective-C class.
Each Python class in "backend_macos.py" inherits from parent class defined in "_macos.m". That parent class creates and then maintains a strong ownership of its Objective-C pair via a struct member called
_object. The Objective-C class has a weakly-held reference back to the Python instance via thepyObjectproperty.This pull request has the following paired classes:
(backend_macos.py)
(_macos.m)
(various .h/.m files)
Future pull requests will introduce MPLTimer and MPLSubplotTool.
Calling Objective-C from Python
PyTypeObjectparent class (in "_macos.m").PyArg_ParseTuple()or a utility method such asMPLGetStringWithPyString().Calling Python from Objective-C
MPLCallMethod()on its_pyObjectivar.By design, this is it! Unlike the previous backend, there is no concept of "reaching in" to the Python layer to instantiate events or acquire the GIL (
MPLCallMethod()handles this for you).Anything that needs more advanced logic than a simple
voidcall takes place in Python-land.Notes on Naming
As much as possible, I have tried to keep similar names between each Python-exposed method and the destination Objective-C method. However, there are a few exceptions:
Objective-C includes parameters in the method name.
addItem()withtitleanddescriptionparameters would beaddItemWithTitle:description:in Objective-C.setFoo:,foo, andgetFoo:have a different meaning in Objective-C compared to Python.setFoo:andfooare accessor methods for a property namedfoo. Thegetprefix is reserved for methods that fetch multiple values, such asgetDeviceX:deviceY:. I useupdateFoo:in situations where there is nofoogetter.Per-class Overview
MPLAppDelegate
This is an "optional" class that implements niceties like the main menu and app icon. It should only be instantiated by the macOS backend when there is no existing NSApplication delegate. Else, we can prevent already-running apps from embedding Python and using us.
In a future PR, this class will generate a macOS-styled app icon from image resources.
I'm also investigating adding more main menu items for accessibility reasons.
MPLFigureManager
Previously, some of this logic lived in an
NSWindowsubclass namedWindow(which is a dangerous name). Some logic lived inViewas it was the window's delegate (this was incorrect as it inverts the traditional object-ownership graph).I made the following notable changes:
MPLFigureManageris now anNSWindowController. It owns an NSWindow and is the delegate of said window.MPLUnconstrainedWindow. It overrides a single method to disable macOS's window constraining logic.MPLFigureCanvas
Previously, this was
View. A lot of changes here:MPLNavigationToolbar2
Previously, this was
NavigationToolbar2Handler.Notable changes:
toolitemsdata to the Objective-C layer, viaadd_item()andadd_separator().set_history_buttons(), similar to other backends._update_buttons_checked(), similar to other backends.A future PR will update the appearance of the buttons and address issues with macOS Tahoe's (rather-ridiculously-sized) window corners overlapping the home button.
Drawing Changes
macOS's AppKit framework has various ways of compositing views to the screen.
Previously, we were using
-setNeedsDisplay:and-drawRect:.-drawRect:would call into Python, grab the Agg-rendered buffer, wrap it in a CGImage, and then draw it viaCGContextDrawImage.The actual call to
-setNeedsDisplay:occurred in response to a single-shot timer firing.Ultimately, this approach resulted in double drawing and flicker.
We now use layer-hosting. This is a lower-level API designed to give app developers direct control over Core Animation. We don't need to animate, but we do need to get our buffer to the lower levels of macOS without AppKit trying to redraw it.
All of the single-shot timer logic goes away. Instead:
draw_idle()and other methods route to_request_display_layer.-[MPLFigureCanvas requestDisplayLayerWithNeedsDraw:]. If called on a worker thread, wedispatch_asyncthe call to the main thread._needsDrawOnNextDisplayLayerflag is set and-[CALayer setNeedsDisplay]is called on the backingCALayer.If called on the main thread (which is usually the case), this guarantees that a layer update will occur on the current iteration of the event loop. Any use of a timer would instead defer to the next iteration.
Later in the event loop, we enter the drawing phase:
CALayerinvokes-displayLayer:on its delegate (ourMPLFigureCanvas)._handle_display_layeron our paired Python object.CALayer.contentsproperty. It's now up to macOS to render/composite the layer.Since the
NSViewis layer-hosted, theCALayerhas sRGB contents, and theNSWindowis also in sRGB, there shouldn't be any CPU-based compositing in our process. This is a macOS implementation detail, however, and may vary between macOS versions.In the worst case, macOS decides to re-composite the layer on the CPU in WindowServer. In the best case, macOS lets the GPU handle it. It's beyond our control. That said, with this PR, no compositing occurs in our own process on my test devices.
LegacyMac / Backwards Compatibility
While we are in the process of migrating to the new macos backend, I didn't want to have files named "macosx" and "macos" that also have similar content.
Ideally, I'd prefer to not have "backend_macosx.py" at all as I keep opening it by accident. I chose "legacymac" for now since it starts with an "L" and doesn't pop up when I type "backend_m".
I had AI make suggestions and create the "backend_macosx.py" compatibility shim. I could use some guidance on this approach as I don't want to break projects using backend_macosx directly.
Is there a better way?
AI Disclosure
PR quality check
Remaining Work
Once this is in, I'll be able to create the following independent PRs which can be reviewed and merged independently of each other:
Additionally:
"macosx"/"macos"/"osx"in_fix_ipython_backend2gui.