From 350ad12cdbe755d7b1d695bb48449e149ad39c9d Mon Sep 17 00:00:00 2001 From: Collin Date: Sat, 11 Jul 2026 11:57:36 +0800 Subject: [PATCH 1/2] fix(textinput): center single-line text correctly at >100% display scale WindowsTextInputComponentView::GetContentSize() measured RichEdit content with a DPI of `pointScaleFactor * GetDpiForSystem()` and then divided the returned natural size by `pointScaleFactor`. TxGetNaturalSize actually reports in the device pixels of the measuring DC (GetDC(nullptr) -> screen DC at the system DPI, typically 96), which is independent of the per-monitor display scale in pointScaleFactor. As a result content height was under-measured by exactly the display scale at any scale > 100%, and calculateContentVerticalOffset() over-centered the text so it parked low and was bottom-clipped. Normalize both conversions by the DC's real DPI (GetDeviceCaps LOGPIXELS): extent uses DIP<->HIMETRIC at the fixed 96 DIPs/inch, and device px are converted back to DIPs with px*96/hdcDpi. Link Gdi32 explicitly for GetDeviceCaps. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../WindowsTextInputComponentView.cpp | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp index 2fd8fcde9a1..cc435bd6a99 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp @@ -55,6 +55,12 @@ std::string BstrToStdString(BSTR bstr, int cp = CP_UTF8) { return str; } +// GetDeviceCaps (used in GetContentSize to normalize RichEdit's device-pixel +// measurements to DIPs) is exported from Gdi32. Microsoft.ReactNative does not +// otherwise reference Gdi32, so link it explicitly to avoid an unresolved +// external symbol (LNK2019) in builds that don't already pull it in transitively. +#pragma comment(lib, "Gdi32.lib") + MSO_CLASS_GUID(ITextHost, "13E670F4-1A5A-11cf-ABEB-00AA00B65EA1") // IID_ITextHost MSO_CLASS_GUID(ITextServices, "8D33F740-CF58-11CE-A89D-00AA006CADC5") // IID_ITextServices MSO_CLASS_GUID(ITextServices2, "8D33F741-CF58-11CE-A89D-00AA006CADC5") // IID_ITextServices2 @@ -1314,13 +1320,25 @@ std::pair WindowsTextInputComponentView::GetContentSize() const no // Use the layout width as the constraint (always multiline) float availableWidth = m_layoutMetrics.frame.size.width; - float scale = m_layoutMetrics.pointScaleFactor; - float dpi = m_layoutMetrics.pointScaleFactor * GetDpiForSystem(); + + // TxGetNaturalSize measures in the *device pixels of `hdc`*. GetDC(nullptr) + // returns a screen DC whose logical DPI (GetDeviceCaps LOGPIXELS) is the system + // DPI - typically 96 - and is unrelated to the per-monitor display scale carried + // in pointScaleFactor. The previous code conflated the two + // (dpi = pointScaleFactor * GetDpiForSystem()) and then divided the returned + // natural size by pointScaleFactor, so at any display scale > 100% the measured + // content height came back short by exactly that scale and + // calculateContentVerticalOffset() over-centered the text (parked low / + // bottom-clipped). Normalize both conversions by the DC's real DPI instead. DIPs + // are 1/96in by definition, so the DIP<->HIMETRIC leg always uses 96. + const int hdcDpiX = GetDeviceCaps(hdc, LOGPIXELSX); + const int hdcDpiY = GetDeviceCaps(hdc, LOGPIXELSY); constexpr float HIMETRIC_PER_INCH = 2540.0f; + constexpr float DIPS_PER_INCH = 96.0f; SIZE extentHimetric = { - static_cast(availableWidth * scale * HIMETRIC_PER_INCH / dpi), - static_cast(std::numeric_limits::max() * HIMETRIC_PER_INCH / dpi)}; + static_cast(availableWidth * HIMETRIC_PER_INCH / DIPS_PER_INCH), + static_cast(std::numeric_limits::max() * HIMETRIC_PER_INCH / DIPS_PER_INCH)}; SIZE naturalSize = {0, 0}; @@ -1340,8 +1358,10 @@ std::pair WindowsTextInputComponentView::GetContentSize() const no return {0.0f, 0.0f}; } - float contentWidth = static_cast(naturalSize.cx) / scale; - float contentHeight = static_cast(naturalSize.cy) / scale; + // naturalSize is in the DC's device pixels; convert device px -> DIPs using the + // DC's actual DPI (px * 96 / hdcDpi), not pointScaleFactor. + float contentWidth = static_cast(naturalSize.cx) * DIPS_PER_INCH / hdcDpiX; + float contentHeight = static_cast(naturalSize.cy) * DIPS_PER_INCH / hdcDpiY; return {contentWidth, contentHeight}; } From 371bfd6ad400759b2c1e8c8b17f1973c186ec33b Mon Sep 17 00:00:00 2001 From: Collin Date: Sat, 11 Jul 2026 16:42:38 +0800 Subject: [PATCH 2/2] Change files --- .../react-native-windows-fix-textinput-centering-dpi.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/react-native-windows-fix-textinput-centering-dpi.json diff --git a/change/react-native-windows-fix-textinput-centering-dpi.json b/change/react-native-windows-fix-textinput-centering-dpi.json new file mode 100644 index 00000000000..f9479e65b4f --- /dev/null +++ b/change/react-native-windows-fix-textinput-centering-dpi.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Fix single-line TextInput text/caret sitting low and bottom-clipped at >100% display scale (GetContentSize DPI normalization)", + "packageName": "react-native-windows", + "email": "collindanielschneide@gmail.com", + "dependentChangeType": "patch" +}