diff --git a/src/OpenClaw.Shared/MenuSizingHelper.cs b/src/OpenClaw.Shared/MenuSizingHelper.cs new file mode 100644 index 0000000..72db271 --- /dev/null +++ b/src/OpenClaw.Shared/MenuSizingHelper.cs @@ -0,0 +1,28 @@ +namespace OpenClaw.Shared; + +/// +/// Pure helper methods for constraining popup menu size to the visible work area. +/// +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; + 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 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 7ce7565..7b486a1 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; @@ -37,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); @@ -66,6 +70,11 @@ private struct MONITORINFO public RECT rcWork; public uint dwFlags; } + + private enum MonitorDpiType + { + MDT_EFFECTIVE_DPI = 0 + } #endregion public event EventHandler? MenuItemClicked; @@ -147,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); @@ -284,8 +292,67 @@ 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 (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 bool TryGetCurrentMonitorMetrics(out int workAreaHeight, out uint dpi) + { + workAreaHeight = 0; + dpi = 96; + + 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; + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + 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/MenuPositionerTests.cs b/tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs index 0249c3f..131cea6 100644 --- a/tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs +++ b/tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs @@ -82,6 +82,45 @@ public void TaskbarAtBottom_TypicalScenario() $"Menu bottom edge {y + MenuHeight} should not exceed work area bottom 1040"); } + [Fact] + public void OversizedMenuHeight_IsClampedToWorkAreaHeight() + { + const int oversizedMenuHeight = 1200; + var visibleHeight = MenuSizingHelper.CalculateWindowHeight( + oversizedMenuHeight, + WorkBottom - WorkTop); + + 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() + { + // Regression test for the tray popup overflow bug: + // 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, visibleHeight, + WorkLeft, WorkTop, WorkRight, WorkBottom); + + Assert.True(y >= WorkTop, $"Menu Y {y} should not be above the work area top {WorkTop}"); + Assert.True( + y + visibleHeight <= WorkBottom, + $"Menu bottom edge {y + visibleHeight} should not exceed work area bottom {WorkBottom}"); + } + [Fact] public void TaskbarAtRight_Scenario() { diff --git a/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs b/tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs new file mode 100644 index 0000000..12b7214 --- /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 (File.Exists(Path.Combine(directory.FullName, "moltbot-windows-hub.slnx")) && + Directory.Exists(Path.Combine(directory.FullName, "src"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not find repository root."); + } +}