Skip to content

[0.83] fix(textinput): center single-line text correctly at >100% display scale - #16302

Open
FaithfulAudio wants to merge 3 commits into
microsoft:0.83-stablefrom
FacilitronWorks:fix/textinput-vertical-centering-dpi
Open

[0.83] fix(textinput): center single-line text correctly at >100% display scale#16302
FaithfulAudio wants to merge 3 commits into
microsoft:0.83-stablefrom
FacilitronWorks:fix/textinput-vertical-centering-dpi

Conversation

@FaithfulAudio

@FaithfulAudio FaithfulAudio commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Problem

On a single-line <TextInput> at any display scale > 100% (125%, 250%, …), the text and caret sit too low in the control and are clipped at the bottom.

Root cause

WindowsTextInputComponentView::GetContentSize() measures RichEdit content via TxGetNaturalSize, which returns sizes in the device pixels of the measuring DC. The DC comes from GetDC(nullptr) — a screen DC whose logical DPI (GetDeviceCaps(hdc, LOGPIXELS)) is the system DPI (typically 96), unrelated to the per-monitor scale in pointScaleFactor. The code conflated the two: it built the HIMETRIC extent with dpi = pointScaleFactor * GetDpiForSystem() and divided the returned natural size by pointScaleFactor. Net: measured content height is short by exactly the display scale, so calculateContentVerticalOffset() centers with an undersized contentHeight and pushes the text down and off the bottom.

Fix

Normalize both conversions by the DC's real DPI: DIP↔HIMETRIC always uses 96 (DIPs are 1/96in by definition); device px → DIP uses px * 96 / GetDeviceCaps(hdc, LOGPIXELS). pointScaleFactor/GetDpiForSystem() are no longer used in the measurement. Gdi32.lib is linked explicitly (#pragma comment) — GetDeviceCaps is a Gdi32 export and Microsoft.ReactNative does not otherwise reference it (LNK2019 without it in a framework source build).

Validation

Bug reproduced in a production RNW 0.83.2 new-arch app (Facilitron FIT — an Expo monorepo app shipping a full Windows target) on Windows 11 ARM64, Debug, physical panel at a true 250% display scale. A stock single-line TextInput (deep Libraries/Components/TextInput/TextInput import, 44-DIP box, fontSize 18) parks its text on the bottom border and clips, while the app-side workaround this patch obsoletes (bottom padding fontSize*2.3*(1-1/scale)) centers the same input:

before: raw vs shimmed at 250%

This diff has been compiled into Microsoft.ReactNative from source on this machine (yarn-patch framework source build; the changed measurement verified present in the built binary — and the Gdi32.lib pragma requirement was found that way). Remaining for upstream: CI build + pixel-level after-shots at 100/125/150/250%.

Caveats for reviewers: base commit 8e869d7 is itself "[0.83] fix text input scaling (#16291)" — please confirm this refines (does not regress) that change. Authored against 0.83-stable to match our production app; happy to re-cut onto main if preferred. Separately noted (not addressed here): pointScaleFactor can go stale across monitor moves (no WM_DPICHANGED refresh).

Microsoft Reviewers: Open in CodeFlow

FaithfulAudio and others added 2 commits July 11, 2026 11:57
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) <noreply@anthropic.com>
@azure-pipelines

Copy link
Copy Markdown
Contributor
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@FaithfulAudio

Copy link
Copy Markdown
Contributor Author

@microsoft-github-policy-service agree [company="Facilitron"]

@FaithfulAudio

Copy link
Copy Markdown
Contributor Author

@microsoft-github-policy-service agree company="Facilitron"

@FaithfulAudio

Copy link
Copy Markdown
Contributor Author

Update — honest validation results from a patched framework source build, including a finding reviewers should weigh.

We compiled this fix into Microsoft.ReactNative from source (binary-gated: patched dll confirmed as the loaded module) and re-ran the probe. On THIS machine the raw input rendered pixel-identical before/after — for an instructive reason: the machine is DPI-degenerate for this bug (system DPI is 240, equal to the 2.5 pointScaleFactor, so the old ÷pointScaleFactor and corrected ÷(hdcDpi/96) coincide). The patch's correctness argument stands for the common case where system DPI ≠ per-monitor scale (e.g. 96-DPI system with a 250% monitor), but we could not demonstrate a visual delta on this hardware.

More significant: programmatic ink measurement shows the rendered offset equals (boxHeight − 0)/2 — i.e. GetContentSize is effectively returning contentHeight ≈ 0 here, in stock and patched code. We believe the cy extent computation (LONG_MAX·2540/96) overflows LONG, which would zero the measured natural size and may be the dominant root cause of the mis-centering — worth investigating alongside (or ahead of) the DPI normalization in this PR. Happy to rework the PR to clamp/fix the extent overflow if maintainers agree with that reading.

After-shot (unchanged rendering, this machine): after

@FaithfulAudio
FaithfulAudio marked this pull request as ready for review July 18, 2026 12:51
@FaithfulAudio
FaithfulAudio requested a review from a team as a code owner July 18, 2026 12:51
@azure-pipelines

Copy link
Copy Markdown
Contributor
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes single-line Fabric <TextInput> vertical centering/clipping at display scales >100% by correcting how RichEdit’s TxGetNaturalSize results (device pixels for the measuring DC) are converted back into DIPs.

Changes:

  • Normalize TxGetNaturalSize measurements using the measuring DC’s DPI (GetDeviceCaps(LOGPIXELSX/Y)) and a constant 96 DIPs/inch conversion for DIP↔HIMETRIC.
  • Link Gdi32.lib explicitly to satisfy GetDeviceCaps usage in framework-source builds.
  • Add a change file to ship the fix as a patch.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp Fixes DPI normalization in RichEdit content measurement used for single-line vertical centering; adds explicit Gdi32.lib link pragma.
change/react-native-windows-fix-textinput-centering-dpi.json Declares the patch change for release notes/versioning.
Suppressed comments (1)

vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp:1364

  • GetDeviceCaps(hdc, LOGPIXELSX/Y) can return 0 on failure; dividing by hdcDpiX/hdcDpiY would then be a divide-by-zero. Even if this is unlikely for a screen DC, the guard is inexpensive and prevents a hard crash.
  // 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<float>(naturalSize.cx) * DIPS_PER_INCH / hdcDpiX;
  float contentHeight = static_cast<float>(naturalSize.cy) * DIPS_PER_INCH / hdcDpiY;

Comment on lines +1339 to +1341
SIZE extentHimetric = {
static_cast<LONG>(availableWidth * scale * HIMETRIC_PER_INCH / dpi),
static_cast<LONG>(std::numeric_limits<LONG>::max() * HIMETRIC_PER_INCH / dpi)};
static_cast<LONG>(availableWidth * HIMETRIC_PER_INCH / DIPS_PER_INCH),
static_cast<LONG>(std::numeric_limits<LONG>::max() * HIMETRIC_PER_INCH / DIPS_PER_INCH)};
@acoates-ms acoates-ms changed the title fix(textinput): center single-line text correctly at >100% display scale [0.83] fix(textinput): center single-line text correctly at >100% display scale Aug 3, 2026
// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we work out some way to do this without loading gdi32.dll. We really dont want to be taking the performance costs of loading additional dlls. And we shouldn't require gdi for RNW.

@microsoft-github-policy-service microsoft-github-policy-service Bot added the Needs: Author Feedback The issue/PR needs activity from its author (label drives bot activity) label Aug 4, 2026
…onversion

Two review items from acoates-ms and the automated reviewer.

1. No Gdi32. GetDeviceCaps is replaced by GetDpiForSystem(), which is exported
   from user32 and is already called elsewhere in this file, so the fix no
   longer pulls an additional DLL into the process. For a GetDC(nullptr) screen
   DC the two report the same thing - the system DPI for the caller's
   DPI-awareness context - so the correction is unchanged, only its source.
   Windows reports square logical DPI for the screen (LOGPIXELSX == LOGPIXELSY),
   so a single value covers both axes and the #pragma comment(lib, "Gdi32.lib")
   is gone.

2. Out-of-range floating-point to integer conversion. The unbounded height
   constraint computed LONG_MAX * 2540 / 96 (~5.7e10) and cast it back to LONG,
   which is undefined behavior. LONG_MAX already means "unbounded" to RichEdit,
   so it is now passed directly with no arithmetic.
Copilot AI review requested due to automatic review settings August 4, 2026 17:51
@microsoft-github-policy-service microsoft-github-policy-service Bot removed the Needs: Author Feedback The issue/PR needs activity from its author (label drives bot activity) label Aug 4, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (1)

vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp:1335

  • The implementation now contradicts the PR's stated fix and validation: the description says this reads GetDeviceCaps(hdc, LOGPIXELS*), links Gdi32.lib, and no longer uses GetDpiForSystem(), while this line does the opposite. Please either update the description/validation to document and substantiate the GetDpiForSystem() equivalence used here, or restore the described DC-specific query; otherwise reviewers cannot tell which implementation was actually built and tested.
  const UINT hdcDpi = GetDpiForSystem();

@FaithfulAudio

Copy link
Copy Markdown
Contributor Author

Thanks — both addressed in the latest push.

No Gdi32. GetDeviceCaps is gone; the DPI now comes from GetDpiForSystem(), which is exported from user32 and is already called elsewhere in this file, so nothing new is loaded. For a GetDC(nullptr) screen DC the two report the same value — the system DPI for the caller's DPI-awareness context — so the correction itself is unchanged, only where it reads the number from. The screen's logical DPI is square (LOGPIXELSX == LOGPIXELSY), so a single value covers both axes and the #pragma comment(lib, "Gdi32.lib") is removed.

Out-of-range conversion. Good catch from the automated reviewer, and it was a real defect rather than a style point: the unbounded height constraint computed LONG_MAX * 2540 / 96 (~5.7e10) and cast that back to LONG, which is UB. LONG_MAX already means "unbounded" to RichEdit, so it is now passed through with no arithmetic at all.

One note in case it matters for how you'd like this to land: the fix is deliberately confined to GetContentSize. The DPI conflation it corrects (pointScaleFactor * GetDpiForSystem() used as a DC DPI) is the only place I found where those two are multiplied together, but I have not audited the rest of the file for the same pattern — happy to widen it if you'd prefer.

@acoates-ms

Copy link
Copy Markdown
Contributor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Contributor
Azure Pipelines:
Successfully started running 1 pipeline(s).

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.

3 participants