fix: refresh tray menu sizing on DPI changes

Size the tray menu against the target cursor monitor DPI instead of the hidden window's stale size, and invalidate cached flyout geometry when DPI or rasterization scale changes. This keeps the first tray menu open after a display-scale change from rendering with stale compressed measurements.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Scott Hanselman 2026-05-05 13:57:33 -07:00
parent e75d9bc1d9
commit 32830a0527
4 changed files with 103 additions and 28 deletions

View File

@ -5,6 +5,8 @@ namespace OpenClaw.Shared;
/// </summary>
public static class MenuSizingHelper
{
private const double ScaleTolerance = 0.001;
public static int ConvertPixelsToViewUnits(int pixels, uint dpi)
{
if (pixels <= 0) return 0;
@ -13,6 +15,19 @@ public static class MenuSizingHelper
return Math.Max(1, (int)Math.Floor(pixels * 96.0 / dpi));
}
public static bool HasDpiOrScaleChanged(uint previousDpi, double previousRasterizationScale, uint currentDpi, double currentRasterizationScale)
{
previousDpi = NormalizeDpi(previousDpi);
currentDpi = NormalizeDpi(currentDpi);
if (previousDpi != currentDpi)
return true;
var previousScale = NormalizeScale(previousRasterizationScale);
var currentScale = NormalizeScale(currentRasterizationScale);
return Math.Abs(previousScale - currentScale) > ScaleTolerance;
}
public static int CalculateWindowHeight(int contentHeight, int workAreaHeight, int minimumHeight = 100)
{
if (contentHeight < 0) contentHeight = 0;
@ -25,4 +40,9 @@ public static class MenuSizingHelper
var desiredHeight = Math.Max(contentHeight, minimumVisibleHeight);
return Math.Min(desiredHeight, workAreaHeight);
}
private static uint NormalizeDpi(uint dpi) => dpi == 0 ? 96u : dpi;
private static double NormalizeScale(double scale) =>
double.IsFinite(scale) && scale > 0 ? scale : 1.0;
}

View File

@ -483,7 +483,6 @@ public partial class App : Application
// Rebuild menu content
_trayMenuWindow!.ClearItems();
BuildTrayMenuPopup(_trayMenuWindow);
_trayMenuWindow.SizeToContent();
_trayMenuWindow.ShowAtCursor();
}
catch (Exception ex)

View File

@ -18,6 +18,8 @@ namespace OpenClawTray.Windows;
/// </summary>
public sealed partial class TrayMenuWindow : WindowEx
{
private const int MenuWidthViewUnits = 320;
#region Win32 Imports
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
@ -111,6 +113,8 @@ public sealed partial class TrayMenuWindow : WindowEx
private string? _activeFlyoutKey;
private bool _isShown;
private global::Windows.Graphics.RectInt32? _lastMoveAndResizeRect;
private uint _lastMeasureDpi;
private double _lastMeasureRasterizationScale;
public TrayMenuWindow() : this(ownerMenu: null)
{
@ -188,28 +192,11 @@ public sealed partial class TrayMenuWindow : WindowEx
var monitorInfo = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
GetMonitorInfo(hMonitor, ref monitorInfo);
var workArea = monitorInfo.rcWork;
int menuWidthPx;
int menuHeightPx;
try
{
menuWidthPx = this.AppWindow.Size.Width;
menuHeightPx = this.AppWindow.Size.Height;
}
catch
{
menuWidthPx = 0;
menuHeightPx = 0;
}
if (menuWidthPx <= 0 || menuHeightPx <= 0)
{
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
uint dpi = GetEffectiveMonitorDpi(hMonitor, hwnd);
double scale = dpi / 96.0;
menuWidthPx = (int)(320 * scale);
menuHeightPx = (int)(_menuHeight * scale);
}
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
var dpi = GetEffectiveMonitorDpi(hMonitor, hwnd);
SizeToContent(workArea.Bottom - workArea.Top, dpi);
var menuWidthPx = ConvertViewUnitsToPixels(MenuWidthViewUnits, dpi);
var menuHeightPx = ConvertViewUnitsToPixels(_menuHeight, dpi);
const int margin = 8;
@ -219,7 +206,16 @@ public sealed partial class TrayMenuWindow : WindowEx
workArea.Left, workArea.Top, workArea.Right, workArea.Bottom,
margin);
this.Move(x, y);
var targetRect = new global::Windows.Graphics.RectInt32(x, y, menuWidthPx, menuHeightPx);
if (!RectEquals(_lastMoveAndResizeRect, targetRect))
{
AppWindow.MoveAndResize(targetRect);
_lastMoveAndResizeRect = targetRect;
}
}
else
{
SizeToContent();
}
ApplyRoundedWindowRegion();
@ -251,6 +247,7 @@ public sealed partial class TrayMenuWindow : WindowEx
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
var dpi = GetEffectiveMonitorDpi(hMonitor, hwnd);
SizeToContent(monitorInfo.rcWork.Bottom - monitorInfo.rcWork.Top, dpi);
var submenuWidthPx = ConvertViewUnitsToPixels(280, dpi);
var submenuHeightPx = ConvertViewUnitsToPixels(_menuHeight, dpi);
@ -523,24 +520,60 @@ public sealed partial class TrayMenuWindow : WindowEx
/// </summary>
public void SizeToContent()
{
if (TryGetCurrentMonitorMetrics(out var workAreaHeightPx, out var dpi))
{
SizeToContent(workAreaHeightPx, dpi);
return;
}
SizeToContent(0, 96);
}
private void SizeToContent(int workAreaHeightPx, uint dpi)
{
PrepareLayoutForMeasurement(dpi);
// Measure the actual content size instead of estimating
MenuPanel.Measure(new global::Windows.Foundation.Size(320, double.PositiveInfinity));
MenuPanel.Measure(new global::Windows.Foundation.Size(MenuWidthViewUnits, double.PositiveInfinity));
var desiredHeight = MenuPanel.DesiredSize.Height;
// Add border chrome (1px border top+bottom = 2px, plus small rounding buffer)
var contentHeight = (int)Math.Ceiling(desiredHeight) + 4;
_menuHeight = Math.Max(contentHeight, 100);
if (TryGetCurrentMonitorMetrics(out var workAreaHeightPx, out var dpi))
if (workAreaHeightPx > 0)
{
var workAreaHeight = MenuSizingHelper.ConvertPixelsToViewUnits(workAreaHeightPx, dpi);
_menuHeight = MenuSizingHelper.CalculateWindowHeight(_menuHeight, workAreaHeight);
}
this.SetWindowSize(320, _menuHeight);
this.SetWindowSize(MenuWidthViewUnits, _menuHeight);
ApplyRoundedWindowRegion();
}
private void PrepareLayoutForMeasurement(uint dpi)
{
dpi = dpi == 0 ? 96 : dpi;
var rasterizationScale = RootGrid.XamlRoot?.RasterizationScale ?? dpi / 96.0;
var dpiChanged = _lastMeasureDpi != 0
&& MenuSizingHelper.HasDpiOrScaleChanged(_lastMeasureDpi, _lastMeasureRasterizationScale, dpi, rasterizationScale);
_lastMeasureDpi = dpi;
_lastMeasureRasterizationScale = rasterizationScale;
if (dpiChanged)
{
_lastMoveAndResizeRect = null;
HideActiveFlyout();
}
RootGrid.InvalidateMeasure();
RootGrid.InvalidateArrange();
MenuPanel.InvalidateMeasure();
MenuPanel.InvalidateArrange();
RootGrid.UpdateLayout();
}
private bool TryGetCurrentMonitorMetrics(out int workAreaHeight, out uint dpi)
{
workAreaHeight = 0;
@ -685,7 +718,6 @@ public sealed partial class TrayMenuWindow : WindowEx
}
}
flyoutWindow.SizeToContent();
_activeFlyoutOwner = ownerButton;
_activeFlyoutKey = flyoutKey;
}

View File

@ -64,6 +64,30 @@ public class MenuSizingHelperTests
Assert.Equal(800, MenuSizingHelper.ConvertPixelsToViewUnits(1000, 120));
}
[Fact]
public void HasDpiOrScaleChanged_SameDpiAndScale_ReturnsFalse()
{
Assert.False(MenuSizingHelper.HasDpiOrScaleChanged(120, 1.25, 120, 1.25));
}
[Fact]
public void HasDpiOrScaleChanged_DifferentDpi_ReturnsTrue()
{
Assert.True(MenuSizingHelper.HasDpiOrScaleChanged(96, 1.0, 120, 1.25));
}
[Fact]
public void HasDpiOrScaleChanged_DifferentRasterizationScale_ReturnsTrue()
{
Assert.True(MenuSizingHelper.HasDpiOrScaleChanged(120, 1.0, 120, 1.25));
}
[Fact]
public void HasDpiOrScaleChanged_NormalizesInvalidInputs()
{
Assert.False(MenuSizingHelper.HasDpiOrScaleChanged(0, double.NaN, 96, 1.0));
}
// ── CalculateWindowHeight ───────────────────────────────────────
[Fact]