Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/OpenClaw.Shared/MenuSizingHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace OpenClaw.Shared;

/// <summary>
/// Pure helper methods for constraining popup menu size to the visible work area.
/// </summary>
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);
}
}
4 changes: 3 additions & 1 deletion src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ScrollViewer VerticalScrollBarVisibility="Visible"
VerticalScrollMode="Enabled"
HorizontalScrollBarVisibility="Disabled">
<StackPanel x:Name="MenuPanel" Padding="4,6">
<!-- Menu items will be added programmatically -->
</StackPanel>
Expand Down
75 changes: 71 additions & 4 deletions src/OpenClaw.Tray.WinUI/Windows/TrayMenuWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -66,6 +70,11 @@ private struct MONITORINFO
public RECT rcWork;
public uint dwFlags;
}

private enum MonitorDpiType
{
MDT_EFFECTIVE_DPI = 0
}
#endregion

public event EventHandler<string>? MenuItemClicked;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<MONITORINFO>() };
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;
}
}
39 changes: 39 additions & 0 deletions tests/OpenClaw.Tray.Tests/MenuPositionerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
40 changes: 40 additions & 0 deletions tests/OpenClaw.Tray.Tests/TrayMenuWindowMarkupTests.cs
Original file line number Diff line number Diff line change
@@ -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(@"<ScrollViewer[^>]*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.");
}
}