exitModalState() breaks setMouseCursor()

On MacOS (tested version 15.2), the following code after the mouseUp() call will result in the PointingHandCursor being displayed for a fraction of a second, and then the NormalCursor will be displayed. The expected result would be that the PointingHandCursor would be displayed indefinitely.

This behavior is weirdly inconsistent; as a standalone app it sometimes happens, and in GarageBand as an AU plugin it always happens. I’m not sure exactly what the environmental factors are that can cause it.

My understanding of how this works is limited, but from what I can tell, exitModalState() is causing a ModalManager async event to be enqueued that exits the modal. The mouse cursor appears correctly between when mouseUp() returns and when the ModalManager async method is called. Somehow when the component is exited from being modal, the cursor is lost.

Note that this code works perfectly on Windows, no issues whatsoever with lots of testing. Also note that if the mouse cursor is moved fully outside of the component’s bounds and back in, the cursor will be set to the appropriate value.

void MyComponent::mouseDown(...) {
  enterModalState();
  setMouseCursor(MouseCursor::DraggingHandCursor)
}

void MyComponent::mouseUp(...) {
  exitModalState();
  setMouseCursor(MouseCursor::PointingHandCursor)
}

Oh; it may also be worth noting that the order of the exitModalState() and setMouseCursor() calls does not matter. The issue occurs with them in the opposite order as well.

Hmm. I removed all modal management from my plugin, and this makes the mouse cursor stuff work perfectly in standalone mode, and when running as either VST3 or AU in Ableton.

However, there are still mouse cursor problems in GarageBand.

I went back and tried some older versions of my plugin in GarageBand, and indeed they too display issues with mouse cursor management, particularly if the cursor is changed to something non-standard in the mouseDrag or mouseUp callbacks, it tends to not work until the mouse has moved over a different component and back.

I don’t know what GarageBand is doing differently, but it seems worth digging into since it’s so popular, and from what I can tell these issues will affect all JUCE plugins that mess with the cursor state in semi-interesting ways.

I had more time to dig into this today, and will summarize my findings here.

  1. I think the whole idea that modal management is involved was a red herring. I ripped all the modal calls out of my app and the cursor problems still appear.
  2. The cursor management works fine in all DAWs I have tested except the Apple ones (GarageBand and Pro Logic).
  3. In the Apple DAWs, it seems that calling setMouseCursor() from inside a mouseDown or mouseUp handler does not work.
  4. I added some logging inside the MacOS version of MouseCursor::PlatformSpecificHandle::showInWindow(), and it is being called exactly when I would expect, with the right cursors.
  5. For some reason, the call to [c set] is not working when it occurs inside mouseDown or mouseUp.
  6. I logged [NSCursor currentCursor] after the call to [c set] and it is actually returning the expected cursor. So the API call is working, and the current cursor is being changed, but it is NOT being visually updated.
  7. I added a call to forceMouseCursorUpdate() inside my mouseMove and mouseDrag callbacks, and this helps. For example, if the cursor is changed on mouseUp, it won’t visually update immediately, but then if the user moves the mouse at least it will be updated then. So this workaround prevents the cursor from being completely stuck with the wrong visual appearance.
  8. After adding the workaround, I added more logging to showInWindow(): I logged [NSCursor currentCursor] before AND after the [c set] call. It shows that the cursor has the correct value at all times; in other words, it is not somehow being set to an incorrect value somewhere else.
  9. Given that calling forceMouseCursorUpdate() in mouseMove/mouseDrag helps, I tried adding a call to triggerFakeMove in the mouseUp/mouseDown callbacks. It didn’t help.
  10. I tried using Desktop::setMousePosition() in mouseUp/mouseDown to jiggle the mouse, but this did not make things work.

So I am at a loss here. From my logging experiments, it seems that JUCE is calling the NSCursor [c set] method with the correct values at the correct times, and for whatever reason there are cases where the visual appearance of the cursor is not updated, and it’s stuck in the last appearance.

Maybe someone on the JUCE team more experienced with the MacOS APIs can guess what’s happening here. This does not seem specific to my plugin – I think anyone who is using custom cursors that are set in mouseUp/mouseDown in GarageBand or Pro Logic will have these issues.

1 Like

For reference, I’ve found various posts across the web about Logic issues with mouse cursors (not just in AU plugins, but also in the main GUI):

This fundamentally seems to be a bug in GarageBand and Logic Pro, not only in their own mouse cursor handling logic but in how it interacts with AU plugins. It seems that several people have reported this to Apple and nothing has happened. From what I see in the forums, Logic Pro users have known about cursor issues for over a decade and it seems to just be a part of the Logic Pro lore that cursors are weird, and folks just accept that. (???)

So I’m not going to bother trying to talk to the brick wall that is Apple. Maybe if someone knows someone at Apple working on this stuff they can.

But I’ll document that workaround that I’m using in case it’s helpful to anyone else:

  1. First I check PluginHostType::getHostDescription() to see if it contains “Apple”, disabling the workaround code if not.
  2. In any mouseDown or mouseUp method that changes the mouse cursor, I set a mouse_dirty boolean flag to true.
  3. In both mouseMove and mouseDrag, I check the mouse_dirty flag. If it’s set to true, I call the workaround code below and clear the dirty bit.

This isn’t perfect, because the mouse cursor does not visually change the moment the user presses or releases the mouse button. But it fixes it once they move the mouse, which is far better than having the cursor be stuck with the previous appearance until it exits the window.

Note that the main part of the workaround is forceMouseCursorUpdate(), and it fixes most cases. However when using enableUnboundedMouseMovement(), it does not repair the cursor. After some experimentation, I found that the showMouseCursor() call fixes that case.

What an ugly mess.

  auto* const source =
      juce::Desktop::getInstance().getMouseSource(event.source.getIndex());
  source->showMouseCursor(component.getMouseCursor());
  source->forceMouseCursorUpdate();
1 Like