Skip to content

NSViewComponent: divide out the desktop scale in resizeToFitView - #1746

Closed
saagedal wants to merge 70 commits into
juce-framework:masterfrom
acondigital:nsviewcomponent-scaled-resize
Closed

saagedal wants to merge 70 commits into
juce-framework:masterfrom
acondigital:nsviewcomponent-scaled-resize

Conversation

@saagedal

Copy link
Copy Markdown

NSViewComponent::resizeToFitView() copies the NSView frame straight into the component's bounds, but the reverse path multiplies by the desktop scale. When the global scale factor is not 1.0, each pass makes the view bigger. Hosted Audio Unit editors grow without end: the window disappears and the host stops responding. This showed up in Acoustica with User interface size set to 125 %.

Changes

  • juce_gui_extraresizeToFitView() now divides the frame size by the top-level component's desktop scale factor before setBounds, so it undoes what NSViewAttachment::componentMovedOrResized() does when it converts the bounds back with ComponentPeer::getAreaCoveredBy().

Notes for review

  • The loop: the Audio Unit editor's AutoResizingNSViewComponent calls resizeToFitView() on every frame-changed notification (juce_AudioUnitPluginFormat.mm). componentMovedOrResized() then sets the frame to the component's area × scale, which sends the next notification. At 1.0 the != check in componentMovedOrResized() stops it after one pass. At any other scale the sizes never match, so the view grows by the scale factor on every pass.
  • Rounding: the size is still truncated as before, so at a scale of 1.0 nothing changes. At other scales truncation can take the size one pixel down and add one extra pass before it settles. It does settle.
  • Other callers: NSViewComponentWithParent gets the same fix, which covers the macOS LV2 editor through fitToView(). VST3 editors don't resize from their view, so this path doesn't affect them.
  • Upstream: this is a JUCE bug that exists upstream too. It only shows when the host sets Desktop::setGlobalScaleFactor, which is rare for plug-in hosts on macOS.

Testing

Untested. Neither built nor run. It needs a check with Audio Unit editors in Acoustica on macOS at 125 % and at System Default.

saagedal and others added 30 commits April 16, 2024 09:35
# Conflicts:
#	modules/juce_gui_basics/native/juce_Windowing_windows.cpp
Request from AVID: One requirement for integration of your new ARA products into Pro Tools 2024.6 and later is that the products should not be shown in earlier releases of Pro Tools.

Previously we supplied some example code that uses a query of the host version number to determine whether or not to show the plugin. This approach was flawed and could result in the plugin not appearing properly, so we have provided an updated example demonstrating the correct approach. The new example uses a feature query to determine whether the host supports the ARA feature for AAX. This query will indicate that earlier versions of Pro Tools do not support the ARA feature.
# Conflicts:
#	modules/juce_audio_plugin_client/juce_audio_plugin_client_AAX.cpp
#	modules/juce_gui_basics/native/juce_Windowing_windows.cpp
Commented out MIDI initialisation to avoid assertions
Introduce a windowFloatingChild StyleFlag that makes a window float above the
parent passed to Component::addToDesktop() without being globally always-on-top,
and minimise/restore/close together with that parent. The flag encapsulates the
native mechanism per platform:

- Windows: owned top-level window (never WS_CHILD).
- macOS: [parent addChildWindow:ordered:NSWindowAbove], (re)attached in
  setVisible() since ordering the window out can detach a child.
- Linux: real top-level window related to its parent via WM_TRANSIENT_FOR.

All peer changes are gated on the new flag, so existing windows are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add ComponentPeer::windowFloatingChild flag for floating child windows
saagedal and others added 28 commits August 11, 2026 08:35
Avoid a redundant device-type scans fixed stopped device logic
getDisplayScale only ever read integer window-scale keys, so neither GNOME's
text-scaling slider nor KDE's fractional scaling had any effect - both surface
as an Xft DPI above 96, which was watched for changes but never read. Resolve it
from the Xft/DPI XSETTING, falling back to the Xft.dpi X resource in
RESOURCE_MANAGER, and take whichever of that and the window scaling factor is
larger: GNOME folds the window scale and the text scale into Xft/DPI, whereas
XFCE keeps the two apart. Where the window scale is used on its own, apply the
text scaling factor by hand.

Also stop the gsettings scaling-factor branch returning 1.0 when it reads back
"uint32 0". That is modern GNOME's value for "unset", and short-circuiting on it
pinned every distro with gsettings installed to an unscaled display, even on
genuinely high-DPI panels. It now falls through to the remaining sources.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Derive the X11 display scale from the desktop's Xft DPI
A caption click is swallowed rather than passed to DefWindowProc so that the
view keeps repainting while the button is held, and the click is only handed
over once the mouse moves. But DefWindowProc is also what activates and raises
the window for that click, so clicking the title bar of a background window did
nothing at all: it stayed behind and unfocused until the mouse was moved and the
pending click finally reached the system. Pass the click straight to
DefWindowProc when the window is not in the foreground, where the repainting the
swallow protects matters least, and keep the existing behaviour for a caption
press on the active window. Clear the pending click when the button is released
without moving, or the next mouse move over the frame hands a stale click over.

The focus update that follows activation was postponed until the mouse event
that activated the window arrived, but only a click in the client area produces
one. A title-bar click therefore never applied it, leaving the window without
keyboard focus - and, for a DocumentWindow, with its menu bar drawn disabled.
Only postpone the update when the activating click is in the client area, which
is the case it was written for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Activate the window when its title bar is clicked
setAudioDeviceSetup() short-circuits when the requested setup matches the
running one: instead of re-opening the device it restarts it, so that a caller
which stopped the device in order to change the format it is about to play ends
up running again. That restart was guarded on !isPlaying().

isPlaying() cannot be trusted at that point. CoreAudioIODevice::stop() calls
CoreAudioInternal::stop (leaveInterruptRunning = true), which clears the
device's callback but leaves the IOProc registered, and clears the playing flag
only on the branch that also tears the IOProc down. After such a stop the device
reports isPlaying() == true while holding no callback at all, so the restart was
skipped and the callback never reattached. The IOProc kept firing, the output
buffer was cleared on every cycle, and the host application never received a
single callback - playback produced silence indefinitely while the device
reported itself open and playing.

Start unconditionally instead. CoreAudioInternal::start() attaches a callback
only when there is none and calls AudioDeviceStart only when no IOProc is
registered, so restarting a device that genuinely is running costs nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Restart an unchanged device even when isPlaying() is stale
…r's coalescing

VBlankThread set a flagPaintPending latch before triggering, skipped the trigger
while it was set, and cleared it only from handleAsyncUpdate(). That duplicates
what AsyncUpdater already does - triggerAsyncUpdate() is a no-op while its own
message is undelivered - but the copy cannot see whether delivery happened, so
the two could get out of step.

When they did, every vblank was discarded for as long as the delay lasted, which
stopped repainting for the whole process instead of costing only the frames
actually delayed. Let AsyncUpdater be the only thing tracking its own message.

This also subsumes the ScopeGuard added earlier for a listener throwing: with no
latch there is nothing left to strand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…not strand the rest

InternalMessageQueue::dispatchMessages() swapped the whole queue into a local
array before walking it. Dispatching a message can enter a modal loop - an
ApplicationCommandTarget command opening a dialog, a button handler calling
runModalLoop() - and that loop does not return until the dialog closes. Every
message further along the batch had already been taken out of the live queue by
the swap, so the nested message pump could not reach it, and it sat undelivered
for the whole life of the dialog.

On Windows all repainting is gated on a vblank AsyncUpdater callback, so whenever
that message landed in such a batch the entire application stopped repainting for
the life of the dialog - every window, not just the dialog. Timers kept firing
throughout, because the timer thread posts fresh messages into the now-empty
queue, which made the queue look as though it were flowing.

Confirmed by stack trace: the message thread inside dispatchMessages() one frame
below runModalLoop(), with the vblank AsyncUpdater callback count frozen for 23
seconds while the vblank thread triggered 1168 times.

Take one message at a time from the live queue instead, so anything still queued
stays reachable from a nested pump, and keep a wake-up posted while the queue is
non-empty so that pump knows to come back for it. That preserves postMessage()'s
invariant that a non-empty queue always has exactly one wake-up outstanding.
Still bounded by however many were queued on entry, so it returns to the Win32
pump just as the batch version did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fix messages stranded behind a modal loop, and a duplicate vblank paint latch
…oved id

A VST3 can declare, through IPluginCompatibility, the class ids it replaces,
which is how a project saved before a plug-in changed identity keeps resolving.
The plug-in client writes that declaration and juce_vst3_helper reads it into a
moduleinfo.json, but the host side ignored it entirely: the scan only ever looked
at kVstAudioEffectClass, so a saved reference to a superseded id came back as a
missing plug-in even though the installed plug-in was volunteering the mapping.

PluginDescription gains compatibleUniqueIds, holding those ids in the same hashed
forms as uniqueId and deprecatedUid so a host can compare them against a saved
reference directly. Both hashes are recorded for each declared class, because a
stored reference may carry either depending on the JUCE version and the platform
that wrote it.

The scan fills it from the manifest when the bundle has one, since that needs no
instantiation, and otherwise from the factory - which is the only route for a
plug-in built with VST3_AUTO_MANIFEST disabled, and so for every plug-in JUCE
builds that way. A class naming its own current id among those it replaces, as
JUCE does when JUCE_VST3_CAN_REPLACE_VST2 is combined with
JUCE_VST3_COMPATIBLE_CLASSES, is dropped rather than recorded.

The field is serialised with the rest of the description, and its absence in a
list written before this existed reads as "no information" rather than "declares
nothing".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VST3: record the classes a plug-in replaces, so a host can follow a m…
Closing a dialog dropped the whole application to the background: the
foreground went to whatever unrelated application was next in the global
z-order, and keyboard input went with it. Windows picks the next foreground
window itself when the current one is destroyed, and that choice is not
reliable - a trace showed it skipping the Acoustica main window, which sat
directly below the dialog being closed and was its Win32 owner, in favour of
an editor from another process.

Remember the window each peer is raised from, and hand the foreground to it
explicitly just before DestroyWindow. It has to happen there, while we still
hold the foreground, because that is the one condition under which Windows
grants SetForegroundWindow.

Giving the dialog a Win32 owner looks like the tidier fix and is not: for a
non-child window GetParent() returns the owner, and the layout, scaling and
transparency code in this file reads GetParent() as the WS_CHILD parent. Menus
are per-pixel transparent, so an owner offset every one of them by the owner's
screen position and they drew as displaced black frames. The comment on
findWindowToRestoreActivationTo() records that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The owner passed to the native dialog was read as GetActiveWindow() inside
openDialog(), which for an asynchronous chooser runs on the chooser's own
thread. GetActiveWindow() is per-thread, so it returned null there and every
launchAsync dialog was shown with no owner at all - free to fall behind the
application, and not modal against anything.

Read it in open() instead, which is on the message thread in both the
synchronous and asynchronous cases. Starting the thread orders the write
against the reads in openDialog(), so no atomic is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mutter's XWayland bridge sends XdndEnter with version 5 regardless of the
XdndAware version the target advertises, so every file drag from a Wayland
app on GNOME was silently rejected. The later versions only add to version 3,
so accept them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
X11: Accept XDND drags from sources announcing a version newer than 3
…other display

A windowFloatingChild window is attached with addChildWindow:, and a child
window lives in its parent's Space. With "Displays have separate Spaces"
enabled - the macOS default - a child on any other display is not drawn, so
floating windows could not be placed on a second display at all.

Keep a floating child attached only while its frame is on the parent window's
display. Anywhere else it is detached and floats on its own at
NSFloatingWindowLevel with hidesOnDeactivate, so it never covers other
applications, and it is re-attached once it is back on the parent's display.
The attachment is re-evaluated in setBounds() before the frame is applied, in
setVisible(), and when the parent window changes screen. A detached child is
ordered out and back in with the parent's miniaturise and deminiaturise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…splays

macOS: Let floating child windows go on another display
findAllTypesForFile() instantiates and initialises every audio effect class
in a file that has no moduleinfo.json, one after another. A shell housing
dozens of plug-ins can take far longer to discover than a host's scan time
limit allows, and nothing lets the host tell that slow progress from a hang.

VST3PluginFormatHeadless gains onClassDiscoveryStarted, called with each
class's name just before findDescriptionsSlow() instantiates it. A host can
restart its time limit per class, and name the class a hang happened in. The
moduleinfo.json path instantiates nothing and does not call it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VST3: report each class as discovery reaches it
…argv[0]

For the main program, glibc's dladdr reports argv[0] as the file name. When
an app is started through PATH (or a .desktop Exec= line) that is a bare name,
so currentExecutableFile resolved against the working directory and sibling
helper executables could not be found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Linux: resolve the main executable through /proc/self/exe instead of …
glibc declares dladdr1 as (address, info, extra_info, flags); the flag and
the output pointer were swapped, which fails to compile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Linux: fix dladdr1 argument order when resolving the main executable
…le changes

searchPathsForPlugins() returned one bundle path per plugin in the whole lilv
world, so a bundle holding many plugins was reported once per plugin, and
bundles from folders already loaded came back whatever path was asked for. It
now reports each bundle once, and only bundles under the requested paths. The
default locations are always searched, so a host that stores its search path
still sees a later change to LV2_PATH.

pluginNeedsRescanning() returned true unconditionally, so a host that trusts it
dropped and revalidated every LV2 plugin on every scan. It now returns true only
when the URI no longer resolves or the bundle's modification time differs from
the one getDescription() recorded.

getPluginUrisInBundle() lets a host match a bundle path against descriptions,
which name an LV2 plugin by URI. It reads only the loaded manifest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LV2: report each bundle once, and rescan a plug-in only when its bundle changes
…top an endless resize loop

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@saagedal saagedal closed this Sep 14, 2026
@saagedal
saagedal deleted the nsviewcomponent-scaled-resize branch September 14, 2026 14:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants