From c6672a32724cb3d486b9e1af52fb010debb77b00 Mon Sep 17 00:00:00 2001 From: Jacob Penn Date: Thu, 4 Jun 2026 16:34:14 -0700 Subject: [PATCH 1/2] WebView (macOS): add withMinimumDeviceScaleFactor to floor raster density MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AppleWkWebView::withMinimumDeviceScaleFactor(double). On a standard-DPI (1x) display the WKWebView rasterises at one device pixel per CSS pixel; when the host scales the editor up via an ancestor AffineTransform (which enlarges the WebView's peer bounds without changing its CSS layout) the OS upscales that 1x raster and the content reads as blurry — most visibly text. Raising the effective device scale gives the renderer headroom so the upscale stays sharp. It is a floor: a Retina display already exceeds it, so it's a no-op there. This affects rasterisation density and window.devicePixelRatio only — CSS layout is unchanged, so it cannot shift or reflow the UI (unlike a CSS zoom-based fit). macOS impl uses the private WKWebView SPI _setOverrideDeviceScaleFactor: via a guarded objc_msgSend, re-applied in viewDidChangeBackingProperties so it survives moves between displays of differing backingScaleFactor. respondsToSelector + @try make it a silent no-op if a future OS drops the SPI. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../misc/juce_WebBrowserComponent.h | 23 +++++++ .../native/juce_WebBrowserComponent_mac.mm | 67 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h b/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h index bf732b564955..484a9b037bbb 100644 --- a/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h +++ b/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h @@ -280,14 +280,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; From f53b7b0abb28865c1a33f00d9f6587c6c61f3cf6 Mon Sep 17 00:00:00 2001 From: Jacob Penn Date: Thu, 4 Jun 2026 16:45:04 -0700 Subject: [PATCH 2/2] WebView (Windows): withMinimumDeviceScaleFactor via RasterizationScale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Windows counterpart to the macOS withMinimumDeviceScaleFactor floor, on WinWebView2 options. Implemented with ICoreWebView2Controller3:: put_RasterizationScale: the floor is max(requested, monitor scale), and ShouldDetectMonitorScaleChanges is turned off so WebView2 doesn't reset it on DPI changes — it's re-applied from the scale-factor notifier with the live monitor scale instead. No-op on WebView2 runtimes without Controller3. Same purpose as the macOS path: on a 100%-scaled monitor the content otherwise rasterises at 1 device px per CSS px, so the host's ancestor-transform scaling leaves the OS upscaling a 1x raster (blurry, most visibly text). Note that raising RasterizationScale also shrinks the CSS viewport (CSS px = bounds / scale), so it's only appropriate for responsively-laid-out content. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../misc/juce_WebBrowserComponent.h | 24 ++++++++++++ .../juce_WebBrowserComponent_windows.cpp | 38 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h b/modules/juce_gui_extra/misc/juce_WebBrowserComponent.h index 484a9b037bbb..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 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); } };