From df1916501d042a73594ca5c196de35656dc3e468 Mon Sep 17 00:00:00 2001 From: Nich Overend Date: Fri, 20 Mar 2026 21:42:16 +0000 Subject: [PATCH 1/4] Add regression test for #82 tray menu overflow --- .../OpenClaw.Tray.Tests/MenuPositionerTests.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs b/tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs index 0249c3f..1ee391f 100644 --- a/tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs +++ b/tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs @@ -82,6 +82,24 @@ public void TaskbarAtBottom_TypicalScenario() $"Menu bottom edge {y + MenuHeight} should not exceed work area bottom 1040"); } + [Fact] + public void OversizedMenuNearTray_RemainsFullyVisibleWithinWorkArea() + { + // Regression test for the tray popup overflow bug: + // when the menu window is sized taller than the monitor work area, + // positioning alone should still keep the full popup visible. + const int oversizedMenuHeight = 1200; + + var (_, y) = MenuPositioner.CalculatePosition( + 1800, 1060, MenuWidth, oversizedMenuHeight, + WorkLeft, WorkTop, WorkRight, WorkBottom); + + Assert.True(y >= WorkTop, $"Menu Y {y} should not be above the work area top {WorkTop}"); + Assert.True( + y + oversizedMenuHeight <= WorkBottom, + $"Menu bottom edge {y + oversizedMenuHeight} should not exceed work area bottom {WorkBottom}"); + } + [Fact] public void TaskbarAtRight_Scenario() { From 38322b945921ede09d40e34678b87e187ad587d8 Mon Sep 17 00:00:00 2001 From: Nich Overend Date: Fri, 20 Mar 2026 21:46:17 +0000 Subject: [PATCH 2/4] Fix #82 constrain tray menu height to work area --- src/OpenClaw.Shared/MenuSizingHelper.cs | 20 ++++++++++++ .../Windows/TrayMenuWindow.xaml.cs | 32 +++++++++++++++++-- .../MenuPositionerTests.cs | 26 +++++++++++---- 3 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 src/OpenClaw.Shared/MenuSizingHelper.cs diff --git a/src/OpenClaw.Shared/MenuSizingHelper.cs b/src/OpenClaw.Shared/MenuSizingHelper.cs new file mode 100644 index 0000000..fdbe14a --- /dev/null +++ b/src/OpenClaw.Shared/MenuSizingHelper.cs @@ -0,0 +1,20 @@ +namespace OpenClaw.Shared; + +/// +/// Pure helper methods for constraining popup menu size to the visible work area. +/// +public static class MenuSizingHelper +{ + public static int CalculateWindowHeight(int contentHeight, int workAreaHeight, int minimumHeight = 100) + { + if (contentHeight < 0) contentHeight = 0; + if (minimumHeight < 1) minimumHeight = 1; + + if (workAreaHeight <= 0) + return Math.Max(contentHeight, minimumHeight); + + var minimumVisibleHeight = Math.Min(minimumHeight, workAreaHeight); + var desiredHeight = Math.Max(contentHeight, minimumVisibleHeight); + return Math.Min(desiredHeight, workAreaHeight); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs index 7ce7565..c80b0f9 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs @@ -1,6 +1,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; +using OpenClaw.Shared; using OpenClawTray.Helpers; using System; using System.Runtime.InteropServices; @@ -284,8 +285,35 @@ public void SizeToContent() // Separators: ~13px each // Headers: ~30px each // Plus padding: ~16px - _menuHeight = (_itemCount * 36) + (_separatorCount * 13) + (_headerCount * 30) + 16; - _menuHeight = Math.Max(_menuHeight, 100); // minimum + var contentHeight = (_itemCount * 36) + (_separatorCount * 13) + (_headerCount * 30) + 16; + _menuHeight = Math.Max(contentHeight, 100); // minimum + + if (TryGetCurrentMonitorWorkAreaHeight(out var workAreaHeight)) + { + // Constrain the popup to the visible work area so the ScrollViewer gets + // a viewport and the menu stays reachable near the tray/taskbar. + _menuHeight = MenuSizingHelper.CalculateWindowHeight(contentHeight, workAreaHeight); + } + this.SetWindowSize(280, _menuHeight); } + + private static bool TryGetCurrentMonitorWorkAreaHeight(out int workAreaHeight) + { + workAreaHeight = 0; + + if (!GetCursorPos(out POINT pt)) + return false; + + var hMonitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST); + if (hMonitor == IntPtr.Zero) + return false; + + var monitorInfo = new MONITORINFO { cbSize = Marshal.SizeOf() }; + if (!GetMonitorInfo(hMonitor, ref monitorInfo)) + return false; + + workAreaHeight = monitorInfo.rcWork.Bottom - monitorInfo.rcWork.Top; + return workAreaHeight > 0; + } } diff --git a/tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs b/tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs index 1ee391f..a6ba1a9 100644 --- a/tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs +++ b/tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs @@ -83,21 +83,35 @@ public void TaskbarAtBottom_TypicalScenario() } [Fact] - public void OversizedMenuNearTray_RemainsFullyVisibleWithinWorkArea() + public void OversizedMenuHeight_IsClampedToWorkAreaHeight() + { + const int oversizedMenuHeight = 1200; + var visibleHeight = MenuSizingHelper.CalculateWindowHeight( + oversizedMenuHeight, + WorkBottom - WorkTop); + + Assert.Equal(WorkBottom - WorkTop, visibleHeight); + } + + [Fact] + public void OversizedMenuNearTray_WithClampedHeight_RemainsFullyVisibleWithinWorkArea() { // Regression test for the tray popup overflow bug: - // when the menu window is sized taller than the monitor work area, - // positioning alone should still keep the full popup visible. + // the popup height must be constrained before positioning so the + // ScrollViewer can handle overflow within the visible work area. const int oversizedMenuHeight = 1200; + var visibleHeight = MenuSizingHelper.CalculateWindowHeight( + oversizedMenuHeight, + WorkBottom - WorkTop); var (_, y) = MenuPositioner.CalculatePosition( - 1800, 1060, MenuWidth, oversizedMenuHeight, + 1800, 1060, MenuWidth, visibleHeight, WorkLeft, WorkTop, WorkRight, WorkBottom); Assert.True(y >= WorkTop, $"Menu Y {y} should not be above the work area top {WorkTop}"); Assert.True( - y + oversizedMenuHeight <= WorkBottom, - $"Menu bottom edge {y + oversizedMenuHeight} should not exceed work area bottom {WorkBottom}"); + y + visibleHeight <= WorkBottom, + $"Menu bottom edge {y + visibleHeight} should not exceed work area bottom {WorkBottom}"); } [Fact] From e158e4bc951bf2156a67dad4fc9314e37275e633 Mon Sep 17 00:00:00 2001 From: Nich Overend Date: Fri, 20 Mar 2026 23:04:59 +0000 Subject: [PATCH 3/4] Fix #82 clamp tray menu height correctly on high-DPI displays --- src/OpenClaw.Shared/MenuSizingHelper.cs | 8 ++++ .../Windows/TrayMenuWindow.xaml | 4 +- .../Windows/TrayMenuWindow.xaml.cs | 8 +++- .../MenuPositionerTests.cs | 7 ++++ .../TrayMenuWindowMarkupTests.cs | 40 +++++++++++++++++++ 5 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs diff --git a/src/OpenClaw.Shared/MenuSizingHelper.cs b/src/OpenClaw.Shared/MenuSizingHelper.cs index fdbe14a..72db271 100644 --- a/src/OpenClaw.Shared/MenuSizingHelper.cs +++ b/src/OpenClaw.Shared/MenuSizingHelper.cs @@ -5,6 +5,14 @@ namespace OpenClaw.Shared; /// public static class MenuSizingHelper { + public static int ConvertPixelsToViewUnits(int pixels, uint dpi) + { + if (pixels <= 0) return 0; + if (dpi == 0) dpi = 96; + + return Math.Max(1, (int)Math.Floor(pixels * 96.0 / dpi)); + } + public static int CalculateWindowHeight(int contentHeight, int workAreaHeight, int minimumHeight = 100) { if (contentHeight < 0) contentHeight = 0; diff --git a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml index 11828ff..885ccd1 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml +++ b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml @@ -14,7 +14,9 @@ BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}" BorderThickness="1" CornerRadius="8"> - + diff --git a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs index c80b0f9..b26e805 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs @@ -288,19 +288,21 @@ public void SizeToContent() var contentHeight = (_itemCount * 36) + (_separatorCount * 13) + (_headerCount * 30) + 16; _menuHeight = Math.Max(contentHeight, 100); // minimum - if (TryGetCurrentMonitorWorkAreaHeight(out var workAreaHeight)) + if (TryGetCurrentMonitorMetrics(out var workAreaHeightPx, out var dpi)) { // Constrain the popup to the visible work area so the ScrollViewer gets // a viewport and the menu stays reachable near the tray/taskbar. + var workAreaHeight = MenuSizingHelper.ConvertPixelsToViewUnits(workAreaHeightPx, dpi); _menuHeight = MenuSizingHelper.CalculateWindowHeight(contentHeight, workAreaHeight); } this.SetWindowSize(280, _menuHeight); } - private static bool TryGetCurrentMonitorWorkAreaHeight(out int workAreaHeight) + private bool TryGetCurrentMonitorMetrics(out int workAreaHeight, out uint dpi) { workAreaHeight = 0; + dpi = 96; if (!GetCursorPos(out POINT pt)) return false; @@ -314,6 +316,8 @@ private static bool TryGetCurrentMonitorWorkAreaHeight(out int workAreaHeight) return false; workAreaHeight = monitorInfo.rcWork.Bottom - monitorInfo.rcWork.Top; + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + dpi = GetDpiForWindow(hwnd); return workAreaHeight > 0; } } diff --git a/tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs b/tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs index a6ba1a9..131cea6 100644 --- a/tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs +++ b/tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs @@ -93,6 +93,13 @@ public void OversizedMenuHeight_IsClampedToWorkAreaHeight() Assert.Equal(WorkBottom - WorkTop, visibleHeight); } + [Fact] + public void PixelHeight_IsConvertedToViewUnits_UsingDpi() + { + var viewHeight = MenuSizingHelper.ConvertPixelsToViewUnits(1200, 192); + Assert.Equal(600, viewHeight); + } + [Fact] public void OversizedMenuNearTray_WithClampedHeight_RemainsFullyVisibleWithinWorkArea() { diff --git a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs new file mode 100644 index 0000000..880f3cb --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs @@ -0,0 +1,40 @@ +using System.Text.RegularExpressions; + +namespace OpenClaw.Tray.Tests; + +public class TrayMenuWindowMarkupTests +{ + [Fact] + public void TrayMenuWindow_UsesVisibleVerticalScrollbar() + { + var xamlPath = Path.Combine( + GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Windows", + "TrayMenuWindow.xaml"); + + var xaml = File.ReadAllText(xamlPath); + + Assert.Matches( + new Regex(@"]*VerticalScrollBarVisibility=""Visible""", RegexOptions.Singleline), + xaml); + } + + private static string GetRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory != null) + { + if (Directory.Exists(Path.Combine(directory.FullName, ".git")) && + File.Exists(Path.Combine(directory.FullName, "README.md"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not find repository root."); + } +} From 47e08eb89770e91aed73ad2423dfb8049ed5f1e0 Mon Sep 17 00:00:00 2001 From: Nich Overend Date: Sat, 21 Mar 2026 09:41:54 +0000 Subject: [PATCH 4/4] fix: address Copilot tray menu review comments --- .../Windows/TrayMenuWindow.xaml.cs | 41 +++++++++++++++++-- .../TrayMenuWindowMarkupTests.cs | 4 +- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs index b26e805..7b486a1 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs @@ -38,6 +38,9 @@ public sealed partial class TrayMenuWindow : WindowEx [DllImport("user32.dll")] private static extern uint GetDpiForWindow(IntPtr hwnd); + [DllImport("Shcore.dll")] + private static extern int GetDpiForMonitor(IntPtr hmonitor, MonitorDpiType dpiType, out uint dpiX, out uint dpiY); + [DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags); @@ -67,6 +70,11 @@ private struct MONITORINFO public RECT rcWork; public uint dwFlags; } + + private enum MonitorDpiType + { + MDT_EFFECTIVE_DPI = 0 + } #endregion public event EventHandler? MenuItemClicked; @@ -148,8 +156,7 @@ public void ShowAtCursor() if (menuWidthPx <= 0 || menuHeightPx <= 0) { var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); - uint dpi = GetDpiForWindow(hwnd); - if (dpi == 0) dpi = 96; + uint dpi = GetEffectiveMonitorDpi(hMonitor, hwnd); double scale = dpi / 96.0; menuWidthPx = (int)(280 * scale); menuHeightPx = (int)(_menuHeight * scale); @@ -317,7 +324,35 @@ private bool TryGetCurrentMonitorMetrics(out int workAreaHeight, out uint dpi) workAreaHeight = monitorInfo.rcWork.Bottom - monitorInfo.rcWork.Top; var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); - dpi = GetDpiForWindow(hwnd); + dpi = GetEffectiveMonitorDpi(hMonitor, hwnd); return workAreaHeight > 0; } + + private static uint GetEffectiveMonitorDpi(IntPtr hMonitor, IntPtr hwnd) + { + if (hMonitor != IntPtr.Zero) + { + try + { + var hr = GetDpiForMonitor(hMonitor, MonitorDpiType.MDT_EFFECTIVE_DPI, out var dpiX, out var dpiY); + if (hr == 0) + { + if (dpiY != 0) + return dpiY; + + if (dpiX != 0) + return dpiX; + } + } + catch (DllNotFoundException) + { + } + catch (EntryPointNotFoundException) + { + } + } + + var dpi = hwnd != IntPtr.Zero ? GetDpiForWindow(hwnd) : 0; + return dpi == 0 ? 96u : dpi; + } } diff --git a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs index 880f3cb..12b7214 100644 --- a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs +++ b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs @@ -26,8 +26,8 @@ private static string GetRepositoryRoot() var directory = new DirectoryInfo(AppContext.BaseDirectory); while (directory != null) { - if (Directory.Exists(Path.Combine(directory.FullName, ".git")) && - File.Exists(Path.Combine(directory.FullName, "README.md"))) + if (File.Exists(Path.Combine(directory.FullName, "moltbot-windows-hub.slnx")) && + Directory.Exists(Path.Combine(directory.FullName, "src"))) { return directory.FullName; }