diff --git a/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h b/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h index bf732b564955..60190b82a08e 100644 --- a/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h +++ b/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h @@ -216,18 +216,42 @@ class JUCE_API WebBrowserComponent : public Component return withMember (*this, &WinWebView2::backgroundColour, colour); } + /** Forces WebView2 to rasterise its content at no less than this scale, regardless + of the monitor's DPI scaling. + + The counterpart to AppleWkWebView::withMinimumDeviceScaleFactor. On a display + at 100% scaling WebView2 otherwise rasterises at one device pixel per CSS pixel; + if the host scales the editor up via an ancestor AffineTransform (which enlarges + the WebView's bounds without changing its CSS layout), the OS upscales that 1x + raster and the content looks soft — most visibly text. Raising the floor gives + the renderer headroom so the upscale stays sharp. A high-DPI monitor already + exceeds this, so it is a floor, not a fixed value. + + Implemented via ICoreWebView2Controller3::put_RasterizationScale, with monitor + auto-detection disabled so the floor isn't reset on DPI changes (it is re-applied + with the live monitor scale instead). Note that raising RasterizationScale also + shrinks the CSS layout viewport (CSS px = bounds / scale), so this is only + appropriate for content that lays out responsively to its viewport. + */ + [[nodiscard]] WinWebView2 withMinimumDeviceScaleFactor (double scale) const + { + return withMember (*this, &WinWebView2::minimumDeviceScaleFactor, scale); + } + //============================================================================== File getDLLLocation() const { return dllLocation; } File getUserDataFolder() const { return userDataFolder; } bool getIsStatusBarDisabled() const noexcept { return disableStatusBar; } bool getIsBuiltInErrorPageDisabled() const noexcept { return disableBuiltInErrorPage; } Colour getBackgroundColour() const { return backgroundColour; } + auto getMinimumDeviceScaleFactor() const { return minimumDeviceScaleFactor; } private: //============================================================================== File dllLocation, userDataFolder; bool disableStatusBar = false, disableBuiltInErrorPage = false; Colour backgroundColour; + std::optional minimumDeviceScaleFactor; }; /** Options specific to the WkWebView backend used on Apple systems. These options will be @@ -280,14 +304,37 @@ class JUCE_API WebBrowserComponent : public Component return withMember (*this, &AppleWkWebView::backgroundColour, colour); } + /** Forces the WKWebView to rasterise its content at no less than this device + scale factor, regardless of the backing scale of the display it sits on. + + On a standard-DPI (1x) display the WebView otherwise renders one device + pixel per CSS pixel. If the host then scales the editor up (e.g. via an + ancestor AffineTransform that enlarges the WebView's peer bounds), the OS + upscales that 1x raster and the content looks soft — most visibly text. + Raising the effective device scale gives the renderer headroom so the + upscale stays sharp. A high-DPI (Retina) display already exceeds this, so + it is a floor, not a fixed value. + + This affects rasterisation density and window.devicePixelRatio only — CSS + layout is unchanged, so it cannot shift or reflow the UI. Implemented via a + private WKWebView SPI and re-applied on display moves; silently ignored if + the running OS doesn't expose it. + */ + [[nodiscard]] AppleWkWebView withMinimumDeviceScaleFactor (double scale) const + { + return withMember (*this, &AppleWkWebView::minimumDeviceScaleFactor, scale); + } + auto getAllowAccessToEnclosingDirectory() const { return allowAccessToEnclosingDirectory; } auto getAcceptsFirstMouse() const { return acceptsFirstMouse; } auto getBackgroundColour() const { return backgroundColour; } + auto getMinimumDeviceScaleFactor() const { return minimumDeviceScaleFactor; } private: bool allowAccessToEnclosingDirectory = false; bool acceptsFirstMouse = true; std::optional backgroundColour; + std::optional minimumDeviceScaleFactor; }; /** Specifies options that apply to the Windows implementation when the WebView2 feature is diff --git a/modules/juce_gui_extra/native/juce_WebBrowserComponent_mac.mm b/modules/juce_gui_extra/native/juce_WebBrowserComponent_mac.mm index 9b4607d0e640..02875b5de458 100644 --- a/modules/juce_gui_extra/native/juce_WebBrowserComponent_mac.mm +++ b/modules/juce_gui_extra/native/juce_WebBrowserComponent_mac.mm @@ -192,6 +192,23 @@ static var fromObject (id object) return getIvar*> (instance, editableFocusActiveMemberName); } +#if JUCE_MAC +// Per-instance pointer to the WKWebViewImpl's minimum-device-scale value (0 = feature +// off). Read in viewDidChangeBackingProperties so the override is (re)applied as the +// window attaches and as it moves between displays of differing backingScaleFactor. +static const char* minDeviceScaleMemberName = "minDeviceScaleHandle"; + +[[maybe_unused]] static void setMinDeviceScaleHandle (id instance, double* value) +{ + object_setInstanceVariable (instance, minDeviceScaleMemberName, value); +} + +[[maybe_unused]] static double* getMinDeviceScaleHandle (id instance) +{ + return getIvar (instance, minDeviceScaleMemberName); +} +#endif + #if JUCE_MAC template struct WebViewKeyEquivalentResponder final : public ObjCClass @@ -203,6 +220,7 @@ explicit WebViewKeyEquivalentResponder (bool acceptsFirstMouse) { this->template addIvar (lastFocusChangeMemberName); this->template addIvar*> (editableFocusActiveMemberName); + this->template addIvar (minDeviceScaleMemberName); // When an editable DOM element has focus (signalled by JS via // __juceSetEditableFocusActive), keys are routed to WKWebView's default @@ -354,6 +372,41 @@ explicit WebViewKeyEquivalentResponder (bool acceptsFirstMouse) if (CALayer* layer = [(NSView*) self layer]) layer.contentsScale = scale; + + // Optional rasterisation-density floor (withMinimumDeviceScaleFactor). + // Re-applied here so it survives moves between displays of differing + // backingScaleFactor. We never go below the display's own backing + // scale, so a Retina screen is unaffected. Affects raster density and + // window.devicePixelRatio only — not CSS layout. + if (double* minScale = getMinDeviceScaleHandle (self); + minScale != nullptr && *minScale > 0.0) + { + const CGFloat effective = jmax ((CGFloat) *minScale, scale); + + // _setOverrideDeviceScaleFactor: is private WKWebView SPI, + // so it isn't in any public header — call it through a + // cast objc_msgSend rather than declaring a category (an + // @interface can't live inside this namespace). Guarded by + // respondsToSelector + @try so a future OS that drops it is + // a silent no-op rather than a crash. + JUCE_BEGIN_IGNORE_WARNINGS_GCC_LIKE ("-Wundeclared-selector") + const SEL overrideSel = @selector (_setOverrideDeviceScaleFactor:); + JUCE_END_IGNORE_WARNINGS_GCC_LIKE + + if ([(NSView*) self respondsToSelector: overrideSel]) + { + @try + { + using SetScaleFn = void (*) (id, SEL, CGFloat); + const auto setScale = reinterpret_cast (objc_msgSend); + setScale (self, overrideSel, effective); + } + @catch (NSException* exception) + { + (void) exception; + } + } + } }); if (acceptsFirstMouse) @@ -1004,6 +1057,15 @@ void setEditableFocusActive (bool active) override setLastFocusChangeHandle (webView.get(), &lastFocusChange); setEditableFocusActiveHandle (webView.get(), &editableFocusActive); + + // Rasterisation-density floor (off unless withMinimumDeviceScaleFactor is set). + // The override is actually applied in viewDidChangeBackingProperties, which + // fires when the view attaches to a window and on every display move, so the + // handle must be in place before addAndMakeVisible below. + minDeviceScaleFactor = browserOptions.getAppleWkWebViewOptions() + .getMinimumDeviceScaleFactor() + .value_or (0.0); + setMinDeviceScaleHandle (webView.get(), &minDeviceScaleFactor); #else webView.reset ([[WKWebView alloc] initWithFrame: CGRectMake (0, 0, 100.0f, 100.0f) configuration: config.get()]); @@ -1325,6 +1387,11 @@ void setEditableFocusActive (bool active) override // to true by the JS layer via __juceSetEditableFocusActive when an // editable DOM element gains focus, so character input reaches the DOM. std::atomic editableFocusActive { false }; + // Rasterisation-density floor (withMinimumDeviceScaleFactor); 0 = off. Held by + // value so the WKWebView subclass can read it through a per-instance handle in + // viewDidChangeBackingProperties. Must outlive the webView — it does, both are + // members of this impl. + double minDeviceScaleFactor = 0.0; ObjCObjectHandle webView; ObjCObjectHandle webViewDelegate; String lastRequestedUrl, lastLoadedUrl; diff --git a/modules/juce_gui_extra/native/juce_WebBrowserComponent_windows.cpp b/modules/juce_gui_extra/native/juce_WebBrowserComponent_windows.cpp index 12ff8f6a7e54..ae793826feb6 100644 --- a/modules/juce_gui_extra/native/juce_WebBrowserComponent_windows.cpp +++ b/modules/juce_gui_extra/native/juce_WebBrowserComponent_windows.cpp @@ -1031,6 +1031,8 @@ class WebBrowserComponent::Impl::Platform::WebView2 final : public WebBrowserCom (BYTE) bgColour.getBlue() }); } + applyRasterizationScaleFloor(); + ComSmartPtr settings; webView->get_Settings (settings.resetAndGetPointerAddress()); @@ -1233,6 +1235,38 @@ class WebBrowserComponent::Impl::Platform::WebView2 final : public WebBrowserCom webViewController->put_IsVisible (shouldBeVisible); } + // Floors the WebView's rasterisation scale (withMinimumDeviceScaleFactor). Off + // unless the option is set. On a 100%-scaled monitor the content otherwise + // rasterises at one device pixel per CSS pixel, so when the host scales the + // editor up via an ancestor transform the OS upscales a 1x raster and the UI + // (most visibly text) looks blurry. We set RasterizationScale to at least the + // requested floor and disable WebView2's own monitor-scale detection so it + // doesn't reset us on DPI changes — re-applied from the scale-factor notifier + // with the live monitor scale instead. Requires the WebView2 runtime to support + // ICoreWebView2Controller3; a no-op on older runtimes. + void applyRasterizationScaleFloor() const + { + const auto minScale = preferences.getWinWebView2BackendOptions().getMinimumDeviceScaleFactor(); + + if (! minScale.has_value() || webViewController == nullptr) + return; + + ComSmartPtr controller3; + webViewController->QueryInterface (controller3.resetAndGetPointerAddress()); + + if (controller3 == nullptr) + return; + + controller3->put_ShouldDetectMonitorScaleChanges (FALSE); + + auto monitorScale = 1.0; + + if (auto* peer = owner.getTopLevelComponent()->getPeer()) + monitorScale = peer->getPlatformScaleFactor(); + + controller3->put_RasterizationScale (jmax (*minScale, monitorScale)); + } + //============================================================================== WebBrowserComponent& owner; WebBrowserComponent::Options preferences; @@ -1275,6 +1309,10 @@ class WebBrowserComponent::Impl::Platform::WebView2 final : public WebBrowserCom NativeScaleFactorNotifier scaleFactorNotifier { this, [this] (auto) { + // Re-apply the rasterisation floor against the new + // monitor scale (auto-detection is off when the floor + // is in use), then refresh bounds. + applyRasterizationScaleFloor(); componentMovedOrResized (true, true); } };