Skip to content

win32 host: APPWINDOW client size used the system DPI, never read back - #32

Merged
defiantnerd merged 1 commit into
mainfrom
fix/win32-appwindow-dpi-readback
Sep 16, 2026
Merged

defiantnerd merged 1 commit into
mainfrom
fix/win32-appwindow-dpi-readback

Conversation

@defiantnerd

@defiantnerd defiantnerd commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Session::widget_show converts the requested CLIENT size to the OUTER size
Win32 wants, via AdjustWindowRectExForDpi. That conversion is DPI-dependent
twice over — the client scales, and so do the border and title-bar metrics —
but the DPI has to be chosen before the HWND exists, so the host used
GetDpiForSystem() (hosts/win32/widgets.cpp:2851).

Nothing corrected it afterwards. When the estimate is wrong, the client comes
out wrong by the ratio of the two DPIs and stays wrong: WM_DPICHANGED
fires when a window moves between monitors, not when it is born on one that
disagrees with the system DPI.

Symptom

With GetDpiForSystem() = 144 and the frame landing on a 96 DPI monitor, a
940x400 request produces a 1416x617 client — the outer size computed for
144 (1432x656) minus the 96 DPI non-client frame.

This is not an exotic configuration. A 150% primary with a 100% secondary hits
it the moment a window opens on the secondary, and so does a remote session
whose desktop DPI differs from the console's. The xpl host's own comment in
create_native_window already calls out this case.

Fix

Read GetDpiForWindow back once the HWND exists and, if it disagrees with the
estimate, redo the client → outer conversion at the real DPI. Placed before
ShowWindow so the correction is never visible as a resize flash.

This is the same read-back the crossplatform host already does at the end of
create_native_window. The native host simply never had it — #29 hardened the
xpl side only.

How it turned up

An editor embedding neui. The shell's own APPWINDOW goes through this host
(neui_get_api(nullptr) returns the native one when it is linked) while the
embedded editor frame goes through the xpl host. So the editor laid itself out
correctly inside a window a third too large, with the UI occupying the
top-left 940x400 of a 1416x617 client.

Verification

On a machine where GetDpiForSystem() = 144 and the primary monitor is 96:

client outer
before 1416x617 1432x656
after 940x400 956x439

and the UI fills the window.

No regression test — and why

This path needs a real frame from the native host, which only comes up under
the WinMain that host provides (hosts/win32/window.cpp). A console harness
like tests/embed_smoke_win32.cpp can't reach it — that one works because it
drives the xpl host, which needs no WinMain. I tried an analogous
appwindow_dpi_smoke_win32 and it never gets an HWND back from show().

A WIN32-subsystem test harness would cover it, but that is a bigger change than
this fix and wants its own way of reporting assertions without stdout. Flagging
it as a follow-up rather than guessing at it here.

create()'s width/height specify the CLIENT area, and Win32 takes an OUTER
size, so widget_show grows the request by the non-client frame via
AdjustWindowRectExForDpi. That conversion is DPI-dependent twice over - the
client scales, and so do the border and title-bar metrics - but the host has
to choose a DPI before the HWND exists, and it used GetDpiForSystem().

Nothing corrected it afterwards. When the estimate is wrong the client comes
out wrong by the ratio of the two DPIs and stays wrong: WM_DPICHANGED fires
when a window MOVES between monitors, not when it is born on one that
disagrees with the system DPI.

With GetDpiForSystem() = 144 and the frame landing on a 96 DPI monitor, a
940x400 request produced a 1416x617 client - the outer size computed for 144
(1432x656) minus the 96 DPI non-client frame. Not an exotic setup: a 150%
primary with a 100% secondary hits it the moment a window opens on the
secondary, and so does a remote session whose desktop DPI differs from the
console's.

Read GetDpiForWindow back once the HWND exists and, if it disagrees with the
estimate, redo the client -> outer conversion at the real DPI. Before
ShowWindow, so the correction is never visible as a resize flash. This is the
same read-back the crossplatform host already does at the end of
create_native_window - the native host simply never had it (#29 hardened the
xpl side only).

Found via an editor embedding neui: the shell's own APPWINDOW goes through
this host (neui_get_api(nullptr) returns the native one when it is linked)
while the embedded editor frame goes through the xpl host, so the editor was
laid out correctly inside a window a third too large.

Verified on a machine where GetDpiForSystem()=144 and the primary monitor is
96: the app's client area goes from 1416x617 to 940x400 (outer 1432x656 ->
956x439) and the UI fills the window.

No regression test: this path needs a real frame from the native host, which
only comes up under the WinMain that host provides, so a console harness like
tests/embed_smoke_win32.cpp (xpl host, no WinMain needed) cannot reach it.
Worth a WIN32-subsystem test harness as a follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLLVDvxV66uC4GiMtb7tHH
@defiantnerd
defiantnerd merged commit 430d0df into main Sep 16, 2026
8 checks passed
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.

1 participant