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.");
+ }
+}