getScreenPosition ignores the scale factor in Cubase/REAPER on Windows

Component::getScreenPosition() returns a logical coordinate on all platforms except for Windows + VST3. Likewise, Desktop::getInstance() -> getGlobalScalingFactor() and ComponentPeer::getPlatformScaleFactor() always return 1.

Note that

  • This only affects plugins, not the standalone.
  • I’ve verified this issue on Cubase 13 and REAPER 7; We’ve seen users of Bitwig also reporting this issue.
  • This also affects MouseInputSource::getScreenPosition().

I’m testing this on a device with just one monitor and that monitor is set to 125% scaling. JUCE_WIN_PER_MONITOR_DPI_AWARE is enabled. To demonstrate this issue, create a boilerplate plugin project with Projucer (version 8.0.8), change the paint method to:

void NewProjectAudioProcessorEditor::paint (juce::Graphics& g)
{
    // (Our component is opaque, so we must completely fill the background with a solid colour)
    g.fillAll (getLookAndFeel().findColour (juce::ResizableWindow::backgroundColourId));

    g.setColour (juce::Colours::white);
    g.setFont (juce::FontOptions (15.0f));

    juce::StringArray infoLines;
    infoLines.add("screen width " + juce::String(getScreenBounds().getWidth()));
    infoLines.add("local width " + juce::String(getLocalBounds().getWidth()));
    infoLines.add("scale factor " + juce::String(getPeer() -> getPlatformScaleFactor()));
    g.drawFittedText (infoLines.joinIntoString(","), getLocalBounds(), juce::Justification::centred, 1);
}

Inside REAPER, this gives me:

While the standalone seems fine:

I believe I’m seeing this issue as well, but with Component::getScreenBounds()

REAPER, Cubase, and Studio One seem to be affected. Live and Pro Tools seem to be ok - however, they appear to be displaying the plugin at 1x resolution, which would likely explain why they are not affected.

JUCE team, could we get a fix for this?

Bump.

The issue also seems to affect Component::localPointToGlobal() and Component::localAreaToGlobal() when called directly, so the root cause is likely detail::ComponentHelpers::convertCoordinate(), which all of the mentioned functions call into.

EDIT: After digging in on it, I think the problem is probably just as simple as the scale factor being wrong, which sounds like is a known issue based on this post: Juce 8 bug - DialogWindow on Windows not scaling correctly - #4 by reuk .

Is there any chance this could get looked at soon? It means a number of functions will not work correctly on Windows.

1 Like

Yes, it’s approaching the top of my backlog. We’re currently planning another point release at the start of next week to address a few issues in 8.0.9. After that, I’m planning to merge a change to split up the juce_audio_processors module to separate out the UI-dependent parts, with the goal of allowing ‘headless’ audio processors to be built without pulling in the juce_gui_basics dependency. This is likely to affect some of the code for displaying plugin editors. I’m hoping to address the windows scaling issues once that change is merged and stable.

2 Likes

Thanks! We’ll be on the lookout for it.

Thanks for your patience. We’ve now published a series of patches intended to address and improve this behaviour.

This commit adds a new mechanism for overriding the platform scale factor detected by the ComponentPeer. Applications/plugins should generally avoid calling this function directly, and instead allow JUCE’s plugin wrappers to call this function when appropriate.

This subsequent change updates the plugin wrappers to call the new ComponentPeer function. Plugins will use the detected native platform scale factor by default, but will override this scale factor if a new factor is requested by the plugin host. Secondary windows (tooltips, popup menus, top-level windows, etc.) created by plugins will continue to match the scale of the target component, if specified, and will use the native platform scale factor otherwise.

These changes should make scaling behaviour in plugins much more consistent across platforms. However, the changes are fairly significant so we recommend testing plugin UIs (especially those that create new top-level windows) thoroughly after applying the new changes. It may also be worth checking your code for places where window-positioning logic is special-cased for Windows, and testing whether this additional logic is still necessary.

Of course, we’d still recommend avoiding additional top-level windows in plugins whenever possible.

2 Likes

Thank you to the JUCE team for the continued support of HiDPI and scaling on Windows. These modifications are related to issues I’ve encountered recently. It’s cool!

And I’ve encountered a strange issue with PopupMenu in AAX plugin. This occurs when using two monitors with different scaling factors and the plugin window spans across both screens. Both monitors are 4K, but with different scaling settings (one at 150%, the other at 200%). As shown in the attached video, the PopupMenu becomes misaligned from its shadow, while the mouse position itself remains correct.

At the same time, there is also an issue with mouse positioning in the standalone. As shown in the second video, the mouse position is offset when moving to the other screen. It’s worth noting that this issue does not occur when JUCE_WIN_PER_MONITOR_DPI_AWARE is set to Disabled. However, I don’t want to disable it, as doing so results in poor HiDPI behavior in my application.

The JUCE branch I’m using is the latest develop branch. And the project I used for testing is really simple, mainly change the paint and mouseDown methods to:

void Hello_world_vst3AudioProcessorEditor::paint (juce::Graphics& g)
{
    // (Our component is opaque, so we must completely fill the background with a solid colour)
    g.fillAll (getLookAndFeel().findColour (juce::ResizableWindow::backgroundColourId));

    g.setColour (juce::Colours::white);
    g.setFont (juce::FontOptions (15.0f));
    g.drawFittedText ("Hello World!", getLocalBounds(), juce::Justification::centred, 1);


    g.setColour(juce::Colours::red);
    g.fillEllipse(clickPosition.x - 2, clickPosition.y - 2, 4, 4);
}

void Hello_world_vst3AudioProcessorEditor::mouseDown(const juce::MouseEvent& e)
{
    if(e.mods.isLeftButtonDown())
    {
        clickPosition = e.getPosition();
        repaint();
    }
    else
    if(e.mods.isRightButtonDown())
    {
        juce::PopupMenu testMenu;
        testMenu.addItem(1, "Test Item 1");
        testMenu.addItem(2, "Test Item 2");
        testMenu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(this).withMousePosition());
    }
}

I apologize if anything I said was unclear or if I posted my questiones in the wrong place, and I’m available to reply to you anytime.

Thanks @reuk! Will try and give this a look before the holiday break.

Following up:

I built a demo plugin from the latest develop tip and updated the paint()method with the code that @khuasw1 pasted above. Scaling and sizing appears to be working as expected, thanks! (For reference, I in Cubase, Live, Pro Tools, Reaper, and Studio One with with my system display scale set to 150%).

We’ll keep an eye out for regressions when we update our plugins to the latest tip.

2 Likes

I’ve encountered a regression caused by commit b4c28db (ComponentPeer: Add method for overriding native scale factor) regarding mouse over/hit-test detection on Windows when using a desktop scaling factor greater than 100% (e.g., 200%).

Mouse move/enter events stop being received correctly when an “Always On Top” window (from another application, such as another standalone JUCE app) is partially obscuring the JUCE window (standalone or plugin).

I tracked the cause of this issue to the changes made to the HWNDComponentPeer::contains() function in juce_Windowing_windows.cpp. If I revert only the changes to this function to the previous implementation, the issue disappears, and mouse tracking works perfectly with HiDPI scaling.

Code Comparison:

New Code (Broken on HiDPI):

bool contains (Point<int> localPos, bool trueIfInAChildWindow) const override
{
    const auto localPhysical = localPos.toFloat() / getPlatformScaleFactor();
    auto r = D2DUtilities::toRectangle (getWindowScreenRect (hwnd)).toFloat();

    if (! r.withZeroOrigin().contains (localPhysical))
        return false;

    const auto screenPos = (localPhysical + getClientRectInScreen().getPosition().toFloat()).roundToInt();

    auto w = WindowFromPoint (D2DUtilities::toPOINT (screenPos));
    return w == hwnd || (trueIfInAChildWindow && (IsChild (hwnd, w) != 0));
}

Old Code (Working):

bool contains (Point<int> localPos, bool trueIfInAChildWindow) const override
{
    auto r = convertPhysicalScreenRectangleToLogical (D2DUtilities::toRectangle (getWindowScreenRect (hwnd)), hwnd);

    if (! r.withZeroOrigin().contains (localPos))
        return false;

    const auto screenPos = convertLogicalScreenPointToPhysical (localPos + getScreenPosition(), hwnd);

    auto w = WindowFromPoint (D2DUtilities::toPOINT (screenPos));
    return w == hwnd || (trueIfInAChildWindow && (IsChild (hwnd, w) != 0));
}

Visual Comparison:

New Code:

Old Code:

Here is the code I used for the test component:

#pragma once
#include <JuceHeader.h>

class MainComponent : public juce::Component
{
public:
    MainComponent()
    {
        setMouseClickGrabsKeyboardFocus(true);
        setSize(600, 300);
    }

    void paint(juce::Graphics& g) override
    {
        g.fillAll(isMouseOverComp ? juce::Colours::green : juce::Colours::darkred);
        g.setFont(juce::FontOptions(24.0f));
        g.setColour(juce::Colours::white);

        juce::String statusText;
        if (isMouseOverComp)
            statusText << "MOUSE OVER DETECTED";
        else
            statusText << "MOUSE NOT DETECTED";

        g.drawText(statusText, getLocalBounds(), juce::Justification::centred, true);
    }

    void mouseEnter(const juce::MouseEvent&) override { isMouseOverComp = true; repaint(); }
    void mouseExit(const juce::MouseEvent&) override { isMouseOverComp = false; repaint(); }
    void mouseMove(const juce::MouseEvent&) override { isMouseOverComp = true; repaint(); }

private:
    bool isMouseOverComp = false;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(MainComponent)
};

I’ve encountered another regression on Windows related to commits b4c28db and fcf1971. This issue occurs specifically in FL Studio 2025 when dragging a VST3 plugin with a detached window between monitors with different DPI scaling factors.

On FL Studio, when dragging a detached VST3 window from a screen with 100% scaling (Screen A) to a screen with a higher scaling factor, such as 200% (Screen B), the plugin’s scaling is never updated. It remains stuck at the original 100% scaling on Screen B. This behavior was not present prior to these commits and occurs with JUCE_WIN_PER_MONITOR_DPI_AWARE enabled.

In other hosts like Studio One, dragging a VST3 window between screens with different scaling correctly triggers setContentScaleFactor() in juce_audio_plugin_client_VST3.cpp, which updates the UI. However, in FL Studio with detached windows, this function is never called during the drag.

I’ve investigated PluginScaleFactorManager in juce_PluginScaleFactorUtilities.h and found issues with how the timer callback interacts with FL Studio’s detached window behavior. When FL Studio switches a plugin to “detached” mode, it destroys the original editor and creates a new one. This calls startObserving() again, but unlike the initial creation, startTimer() is seemingly not called, preventing the periodic scale check.

Even after modifying the code to ensure the timer remains active, a second issue persists: getPlatformScaleFactor() returns 1.0 even when the detached window is on the 200% screen. In the older code, the timer callback in juce_audio_plugin_client_VST3.cpp called checkHostWindowScaleFactor(). This internally called getScaleFactorForWindow(), which correctly returned the updated scaling value (e.g., 2.0) in this specific detached scenario, whereas the new getPlatformScaleFactor() implementation fails to reflect the change.

Below is a comparison between the current develop branch (up to commit fcf1971) and the older working code (up to commit 83e3cd8).

New Code:

Old Code:

Here is the code I used to for testing:

#pragma once
#include <JuceHeader.h>
#include "PluginProcessor.h"

class ScaleTestAudioProcessorEditor  : public juce::AudioProcessorEditor
{
public:
    ScaleTestAudioProcessorEditor (ScaleTestAudioProcessor& p)
        : AudioProcessorEditor (&p), audioProcessor (p)
    {
        setResizable (true, true);
        setSize (400, 300);
    }

    void paint (juce::Graphics& g) override
    {
        g.fillAll (juce::Colours::black);

        auto bounds = getLocalBounds();
        int checkerSize = 20;
        for (int y = 0; y < bounds.getHeight(); y += checkerSize)
        {
            for (int x = 0; x < bounds.getWidth(); x += checkerSize)
            {
                if (((x / checkerSize) + (y / checkerSize)) % 2 == 0)
                {
                    g.setColour (juce::Colours::darkgrey);
                    g.fillRect (x, y, checkerSize, checkerSize);
                }
            }
        }

        auto centerBox = juce::Rectangle<float>(300, 100).withCentre(bounds.toFloat().getCentre());
        g.setColour (juce::Colours::black.withAlpha (0.7f));
        g.fillRect (centerBox);
        g.setColour (juce::Colours::white);
        g.drawRect (centerBox, 2.0f);

        g.setFont (juce::FontOptions (20.0f));
        
        double scale = g.getInternalContext().getPhysicalPixelScaleFactor();
        
        juce::String text;
        text << "Drawing Scale: " << scale << "\n";
        text << "Bounds: " << getWidth() << "x" << getHeight();

        g.drawFittedText (text, centerBox.toNearestInt(), juce::Justification::centred, 10);
    }

    void resized() override {}

private:
    ScaleTestAudioProcessor& audioProcessor;
    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ScaleTestAudioProcessorEditor)
};