Skip to content

Commit

Permalink
conhost: fix WM_GETDPISCALEDSIZE to use provided size instead of curr…
Browse files Browse the repository at this point in the history
…ent size (#18268)

The conhost window uses the window message WM_GETDPISCALEDSIZE to scale
its client rect non-linearly. This is done to keep the rows and columns
from changing when the window changes (font sizes scale non-linearly).
If you size the window such that the text perfectly fits the width (and
cursor is on the first row of the next line), dragging the window
between monitors with different DPIs should NOT change how much of the
text fits on each line.

https://learn.microsoft.com/en-us/windows/win32/hidpi/wm-getdpiscaledsize

The current code is assuming that the size that should be scaled is the
current window size. This is sometimes the case, for example when
dragging a window between monitors, but it is not always the case. This
message can sometimes contain a size that is different from the window's
current size. For example, if the window is maximized, minimized, or
snapped (the size in these cases is the normal rect, or restore rect).

The msdn page above does (now) call this out, though it is possible that
this was added after this conhost code was added...

> The LPARAM is an in/out pointer to a SIZE struct. The _In_ value in
the LPARAM is the pending size of the window after a user-initiated move
or a call to SetWindowPos.

If the window is being resized, this size is not necessarily the same as
the window's current size at the time this message is received.

This incorrect assumption can cause the conhost window to be
unexpectedly large/small in some cases. For example:

1. Requires two monitors, set to different DPIs.
2. Size window somewhat small, and type text to fit exactly the width of
the window, putting cursor on first row of next line.
3. Win+Left (or otherwise snap/arrange the window).
4. Win+Shift+Left (migrates the window to the other monitor)
5. Win+Shift+Down (restore window, can also click maximize caption
button twice, maximizing then restoring)

Expected: The window should restore to the original logical size, with
the text perfectly fitting one line.

Actual: The window restores to another size; it is the snapped size on
the original monitor (the size of the window at the time it was changing
DPI, in step 4 above).

## References and Relevant Issues

This message (WM_GETDPISCALEDSIZE) is not widely used, but it is used by
dialogs (user32!CreateDialog), since they also size their windows using
font sizes. The code in this change borrows from the code in the dialog
manager, user32!GetDialogDpiScaledSize.

## Detailed Description of the Pull Request / Additional comments

The WM_GETDPISCALEDSIZE message contains the new DPI and the new size,
which is an in/out parameter. It starts as the new window size, scaled
to the window's current DPI, and is expected to be scaled to the new
DPI.

The client area (the part with the text) is NOT scaled linearly. For
example, if the font at 100% DPI has a height of 7, it could have a
height of 15 at 200%. (And if it did have a height of 14, linearly
scaled, it would surely not be linearly scaled at 150%, since fonts
cannot have a height of 10.5.) To pick the right size, we need to
resolve the font at the new DPI and use its actual size to scale the
client area.

To keep the amount of text in the window the same, we need to remove the
non-client area of the window (caption bars, resize borders, etc). The
non-client area is outside the area with the text, and its size depends
on the window's DPI and window styles. To remove it and add it back, we
need to:

- Reduce the provided window rect size by the non-client size at the
current DPI.
 - Scale the client size using the new/old font sizes.
 - Expand the final size by the non-client size at the new DPI.
  • Loading branch information
ekoschik authored Jan 10, 2025
1 parent 3e66902 commit ba87ab5
Show file tree
Hide file tree
Showing 4 changed files with 90 additions and 40 deletions.
1 change: 1 addition & 0 deletions src/cascadia/ut_app/TerminalApp.Unit.Tests.manifest
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
UI Variable, GH #12452 -->
<maxversiontested Id="10.0.22000.0"/>
<maxversiontested Id="10.0.22621.0"/>
<maxversiontested Id="10.0.26100.0"/>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
Expand Down
24 changes: 24 additions & 0 deletions src/interactivity/win32/window.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,30 @@ void Window::s_CalculateWindowRect(const til::size coordWindowInChars,
prectWindow->bottom = prectWindow->top + rectProposed.height();
}

// Expands a rect by the size of the non-client area (caption bar, resize borders,
// scroll bars, etc), which depends on the window styles and DPI
void Window::s_ExpandRectByNonClientSize(HWND const hWnd,
UINT dpi,
_Inout_ til::rect* const prectWindow)
{
DWORD dwStyle = GetWindowStyle(hWnd);
DWORD dwExStyle = GetWindowExStyle(hWnd);
BOOL fMenu = FALSE;

ServiceLocator::LocateWindowMetrics<WindowMetrics>()->AdjustWindowRectEx(
prectWindow, dwStyle, fMenu, dwExStyle, dpi);

// Note: AdjustWindowRectEx does not account for scroll bars :(.
if (WI_IsFlagSet(dwStyle, WS_HSCROLL))
{
prectWindow->bottom += ServiceLocator::LocateHighDpiApi<WindowDpiApi>()->GetSystemMetricsForDpi(SM_CYHSCROLL, dpi);
}
if (WI_IsFlagSet(dwStyle, WS_VSCROLL))
{
prectWindow->right += ServiceLocator::LocateHighDpiApi<WindowDpiApi>()->GetSystemMetricsForDpi(SM_CXVSCROLL, dpi);
}
}

til::rect Window::GetWindowRect() const noexcept
{
RECT rc{};
Expand Down
4 changes: 4 additions & 0 deletions src/interactivity/win32/window.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ namespace Microsoft::Console::Interactivity::Win32
void _HandleDrop(const WPARAM wParam) const;
[[nodiscard]] HRESULT _HandlePaint() const;
void _HandleWindowPosChanged(const LPARAM lParam);
LRESULT _HandleGetDpiScaledSize(UINT dpiNew, _Inout_ SIZE* pSizeNew) const;

// Accessibility/UI Automation
[[nodiscard]] LRESULT _HandleGetObject(const HWND hwnd,
Expand Down Expand Up @@ -173,6 +174,9 @@ namespace Microsoft::Console::Interactivity::Win32
const til::size coordBufferSize,
_In_opt_ HWND const hWnd,
_Inout_ til::rect* const prectWindow);
static void s_ExpandRectByNonClientSize(HWND const hWnd,
UINT dpi,
_Inout_ til::rect* const prectWindow);

static void s_ReinitializeFontsForDPIChange();

Expand Down
101 changes: 61 additions & 40 deletions src/interactivity/win32/windowproc.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -244,47 +244,10 @@ static constexpr TsfDataProvider s_tsfDataProvider;

case WM_GETDPISCALEDSIZE:
{
// This message will send us the DPI we're about to be changed to.
// Our goal is to use it to try to figure out the Window Rect that we'll need at that DPI to maintain
// the same client rendering that we have now.

// First retrieve the new DPI and the current DPI.
const auto dpiProposed = (WORD)wParam;

// Now we need to get what the font size *would be* if we had this new DPI. We need to ask the renderer about that.
const auto& fiCurrent = ScreenInfo.GetCurrentFont();
FontInfoDesired fiDesired(fiCurrent);
FontInfo fiProposed(L"", 0, 0, { 0, 0 }, 0);

const auto hr = g.pRender->GetProposedFont(dpiProposed, fiDesired, fiProposed);
// fiProposal will be updated by the renderer for this new font.
// GetProposedFont can fail if there's no render engine yet.
// This can happen if we're headless.
// Just assume that the font is 1x1 in that case.
const auto coordFontProposed = SUCCEEDED(hr) ? fiProposed.GetSize() : til::size{ 1, 1 };

// Then from that font size, we need to calculate the client area.
// Then from the client area we need to calculate the window area (using the proposed DPI scalar here as well.)

// Retrieve the additional parameters we need for the math call based on the current window & buffer properties.
const auto viewport = ScreenInfo.GetViewport();
auto coordWindowInChars = viewport.Dimensions();

const auto coordBufferSize = ScreenInfo.GetTextBuffer().GetSize().Dimensions();

// Now call the math calculation for our proposed size.
til::rect rectProposed;
s_CalculateWindowRect(coordWindowInChars, dpiProposed, coordFontProposed, coordBufferSize, hWnd, &rectProposed);

// Prepare where we're going to keep our final suggestion.
const auto pSuggestionSize = (SIZE*)lParam;

pSuggestionSize->cx = rectProposed.width();
pSuggestionSize->cy = rectProposed.height();

// Format our final suggestion for consumption.
const auto result = _HandleGetDpiScaledSize(
static_cast<WORD>(wParam), reinterpret_cast<SIZE*>(lParam));
UnlockConsole();
return TRUE;
return result;
}

case WM_DPICHANGED:
Expand Down Expand Up @@ -884,6 +847,64 @@ void Window::_HandleWindowPosChanged(const LPARAM lParam)
}
}

// WM_GETDPISCALEDSIZE is sent prior to the window changing DPI, allowing us to
// choose the size at the new DPI (overriding the default, linearly scaled).
//
// This is used to keep the rows and columns from changing when the DPI changes.
LRESULT Window::_HandleGetDpiScaledSize(UINT dpiNew, _Inout_ SIZE* pSizeNew) const
{
// Get the current DPI and font size.
HWND hwnd = GetWindowHandle();
UINT dpiCurrent = ServiceLocator::LocateHighDpiApi<WindowDpiApi>()->GetDpiForWindow(hwnd);
const auto& fontInfoCurrent = GetScreenInfo().GetCurrentFont();
til::size fontSizeCurrent = fontInfoCurrent.GetSize();

// Scale the current font to the new DPI and get the new font size.
FontInfoDesired fontInfoDesired(fontInfoCurrent);
FontInfo fontInfoNew(L"", 0, 0, { 0, 0 }, 0);
if (!SUCCEEDED(ServiceLocator::LocateGlobals().pRender->GetProposedFont(
dpiNew, fontInfoDesired, fontInfoNew)))
{
// On failure, return FALSE, which scales the window linearly for DPI.
return FALSE;
}
til::size fontSizeNew = fontInfoNew.GetSize();

// The provided size is the window rect, which includes non-client area
// (caption bars, resize borders, scroll bars, etc). We want to scale the
// client area separately from the non-client area. The client area will be
// scaled using the new/old font sizes, so that the size of the grid (rows/
// columns) does not change.

// Subtract the size of the window's current non-client area from the
// provided size. This gives us the new client area size at the previous DPI.
til::rect rc;
s_ExpandRectByNonClientSize(hwnd, dpiCurrent, &rc);
pSizeNew->cx -= rc.width();
pSizeNew->cy -= rc.height();

// Scale the size of the client rect by the new/old font sizes.
pSizeNew->cx = MulDiv(pSizeNew->cx, fontSizeNew.width, fontSizeCurrent.width);
pSizeNew->cy = MulDiv(pSizeNew->cy, fontSizeNew.height, fontSizeCurrent.height);

// Add the size of the non-client area at the new DPI to the final size,
// getting the new window rect (the output of this function).
rc = { 0, 0, pSizeNew->cx, pSizeNew->cy };
s_ExpandRectByNonClientSize(hwnd, dpiNew, &rc);

// Write the final size to the out parameter.
// If not Maximized/Arranged (snapped), this will determine the size of the
// rect in the WM_DPICHANGED message. Otherwise, the provided size is the
// normal position (restored, last non-Maximized/Arranged).
pSizeNew->cx = rc.width();
pSizeNew->cy = rc.height();

// Return true. The next WM_DPICHANGED (if at this DPI) should contain a
// rect with the size we picked here. (If we change to another DPI than this
// one we'll get another WM_GETDPISCALEDSIZE before changing DPI).
return TRUE;
}

// Routine Description:
// - This helper method for the window procedure will handle the WM_PAINT message
// - It will retrieve the invalid rectangle and dispatch that information to the attached renderer
Expand Down

0 comments on commit ba87ab5

Please sign in to comment.