返回提交历史
Added
XFEWorkflow/XFEWorkflow/Controls/TrayMenuWindow.xaml
+47
-0
Added
XFEWorkflow/XFEWorkflow/Controls/TrayMenuWindow.xaml.cs
+141
-0
Added
XFEWorkflow/XFEWorkflow/Services/TrayMenuSmokeTest.cs
+171
-0
Modified
XFEWorkflow/XFEWorkflow/Services/TrayService.cs
+35
-14
Modified
XFEWorkflow/XFEWorkflow/Services/UiSmokeTest.cs
+9
-5
Modified
docs/acceptance.md
+2
-0
Added
docs/screenshots/tray-menu-dark.png
+0
-0
Added
docs/screenshots/tray-menu-light.png
+0
-0
Added
docs/screenshots/tray-menu-ui-results.json
+18
-0
Added
docs/tray.md
+16
-0
XFEstudio/XFEWorkflow
将托盘菜单改为支持浅深主题的 WinUI 云母菜单
8548b6a
代码差异
10 个文件
+439
-19
@@ -0,0 +1,47 @@
1
<Window
2
x:Class="XFEWorkflow.Controls.TrayMenuWindow"
3
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
4
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
5
Title="XFEWorkflow 托盘菜单">
6
<Grid x:Name="Anchor" Loaded="Anchor_Loaded">
7
<Grid.Resources>
8
<ResourceDictionary>
9
<ResourceDictionary.ThemeDictionaries>
10
<ResourceDictionary x:Key="Default">
11
<SolidColorBrush x:Key="TrayMenuBackground" Color="Transparent"/>
12
</ResourceDictionary>
13
<ResourceDictionary x:Key="HighContrast">
14
<SolidColorBrush x:Key="TrayMenuBackground" Color="{ThemeResource SystemColorWindowColor}"/>
15
</ResourceDictionary>
16
</ResourceDictionary.ThemeDictionaries>
17
<Style x:Key="TrayMenuPresenterStyle" TargetType="MenuFlyoutPresenter">
18
<Setter Property="MinWidth" Value="320"/>
19
<Setter Property="CornerRadius" Value="8"/>
20
<Setter Property="BorderThickness" Value="1"/>
21
<Setter Property="BorderBrush" Value="{ThemeResource SurfaceStrokeColorFlyoutBrush}"/>
22
</Style>
23
<Style x:Key="TrayMicaMenuPresenterStyle" TargetType="MenuFlyoutPresenter" BasedOn="{StaticResource TrayMenuPresenterStyle}">
24
<Setter Property="Background" Value="{ThemeResource TrayMenuBackground}"/>
25
</Style>
26
</ResourceDictionary>
27
</Grid.Resources>
28
<Grid.ContextFlyout>
29
<MenuFlyout x:Name="TrayMenu" ShouldConstrainToRootBounds="False" Opened="Menu_Opened" Closed="Menu_Closed">
30
<MenuFlyoutItem x:Name="OpenItem" Text="打开 XFEWorkflow" Tag="Open" Click="Command_Click">
31
<MenuFlyoutItem.Icon><FontIcon Glyph=""/></MenuFlyoutItem.Icon>
32
</MenuFlyoutItem>
33
<MenuFlyoutSeparator/>
34
<MenuFlyoutItem x:Name="StopItem" Text="紧急停止全部任务" Tag="EmergencyStop" KeyboardAcceleratorTextOverride="Ctrl+Alt+F12" Click="Command_Click">
35
<MenuFlyoutItem.Icon><FontIcon Glyph=""/></MenuFlyoutItem.Icon>
36
</MenuFlyoutItem>
37
<MenuFlyoutItem x:Name="ResumeItem" Text="恢复自动调度" Tag="Resume" Click="Command_Click">
38
<MenuFlyoutItem.Icon><FontIcon Glyph=""/></MenuFlyoutItem.Icon>
39
</MenuFlyoutItem>
40
<MenuFlyoutSeparator/>
41
<MenuFlyoutItem x:Name="ExitItem" Text="退出" Tag="Exit" Click="Command_Click">
42
<MenuFlyoutItem.Icon><FontIcon Glyph=""/></MenuFlyoutItem.Icon>
43
</MenuFlyoutItem>
44
</MenuFlyout>
45
</Grid.ContextFlyout>
46
</Grid>
47
</Window>
@@ -0,0 +1,141 @@
1
using System.Runtime.InteropServices;
2
using Microsoft.UI.Composition.SystemBackdrops;
3
using Microsoft.UI.Windowing;
4
using Microsoft.UI.Xaml.Controls.Primitives;
5
using Microsoft.UI.Xaml.Media;
6
using Windows.Graphics;
7
8
namespace XFEWorkflow.Controls;
9
10
internal enum TrayCommand { Open, EmergencyStop, Resume, Exit }
11
12
/// <summary>A dedicated anchor lets a native WinUI menu open while the main window stays hidden.</summary>
13
public sealed partial class TrayMenuWindow : Window, IDisposable
14
{
15
private readonly FrameworkElement ownerRoot;
16
private bool pending, active, closing, disposed;
17
private PointInt32 requestedPoint;
18
private bool canResume;
19
internal event Action<TrayCommand>? CommandInvoked;
20
internal bool IsMenuOpen { get; private set; }
21
internal MenuFlyout Menu => TrayMenu;
22
internal MenuFlyoutPresenter? Presenter => Anchor.XamlRoot is { } root
23
? VisualTreeHelper.GetOpenPopupsForXamlRoot(root).Select(x => x.Child).OfType<MenuFlyoutPresenter>().FirstOrDefault()
24
: null;
25
26
internal TrayMenuWindow(FrameworkElement ownerRoot)
27
{
28
InitializeComponent();
29
this.ownerRoot = ownerRoot;
30
var presenter = OverlappedPresenter.CreateForContextMenu();
31
presenter.SetBorderAndTitleBar(false, false);
32
presenter.IsAlwaysOnTop = true;
33
AppWindow.SetPresenter(presenter);
34
AppWindow.IsShownInSwitchers = false;
35
if (MicaController.IsSupported())
36
{
37
TrayMenu.SystemBackdrop = new MicaBackdrop();
38
TrayMenu.MenuFlyoutPresenterStyle = (Style)Anchor.Resources["TrayMicaMenuPresenterStyle"];
39
}
40
else TrayMenu.MenuFlyoutPresenterStyle = (Style)Anchor.Resources["TrayMenuPresenterStyle"];
41
ownerRoot.ActualThemeChanged += OwnerThemeChanged;
42
Activated += Window_Activated;
43
ApplyTheme();
44
}
45
46
internal void ShowAt(PointInt32 screenPoint, bool canResume)
47
{
48
ObjectDisposedException.ThrowIf(disposed, this);
49
requestedPoint = screenPoint;
50
this.canResume = canResume;
51
pending = true;
52
if (active) { closing = true; TrayMenu.Hide(); }
53
else BeginOpen();
54
}
55
56
private void BeginOpen()
57
{
58
if (disposed || !pending || active) return;
59
ApplyTheme();
60
ResumeItem.IsEnabled = canResume;
61
var area = DisplayArea.GetFromPoint(requestedPoint, DisplayAreaFallback.Nearest).WorkArea;
62
var x = Math.Clamp(requestedPoint.X, area.X, area.X + area.Width - 1);
63
var y = Math.Clamp(requestedPoint.Y, area.Y, area.Y + area.Height - 1);
64
// AppWindow coordinates are physical pixels. WinUI handles the popup's
65
// monitor DPI and keeps the unconstrained menu inside the work area.
66
AppWindow.MoveAndResize(new RectInt32(x, y, 1, 1));
67
AppWindow.Show();
68
Activate();
69
SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(this));
70
DispatcherQueue.TryEnqueue(ShowPendingMenu);
71
}
72
73
private void Anchor_Loaded(object sender, RoutedEventArgs e) => ShowPendingMenu();
74
private void ShowPendingMenu()
75
{
76
if (disposed || !pending || active || Anchor.XamlRoot is null || !Anchor.IsLoaded) return;
77
pending = false;
78
active = true;
79
TrayMenu.ShowAt(Anchor, new FlyoutShowOptions { Position = new(0, 0) });
80
}
81
82
private void OwnerThemeChanged(FrameworkElement sender, object args) => ApplyTheme();
83
private void ApplyTheme()
84
{
85
Anchor.RequestedTheme = ownerRoot.ActualTheme;
86
if (Presenter is { } presenter) presenter.RequestedTheme = ownerRoot.ActualTheme;
87
}
88
89
private void Menu_Opened(object sender, object e)
90
{
91
if (closing) { TrayMenu.Hide(); return; }
92
IsMenuOpen = true; ApplyTheme();
93
}
94
private void Menu_Closed(object sender, object e)
95
{
96
IsMenuOpen = false;
97
active = closing = false;
98
if (disposed) return;
99
if (pending) BeginOpen();
100
else AppWindow.Hide();
101
}
102
private void Window_Activated(object sender, WindowActivatedEventArgs args)
103
{
104
// A queued deactivation from the previous close can arrive after a
105
// new menu has opened. Check the current foreground window first.
106
var foreground = GetForegroundWindow();
107
var handle = WinRT.Interop.WindowNative.GetWindowHandle(this);
108
if (args.WindowActivationState == WindowActivationState.Deactivated && IsMenuOpen
109
&& foreground != handle && GetAncestor(foreground, 3) != handle) Dismiss();
110
}
111
private void Command_Click(object sender, RoutedEventArgs e)
112
{
113
if (sender is not MenuFlyoutItem { Tag: string tag } || !Enum.TryParse<TrayCommand>(tag, out var command)) return;
114
Dismiss();
115
// Let the native flyout finish dispatching its click before opening or
116
// closing windows and releasing the application's services.
117
DispatcherQueue.TryEnqueue(() => { if (!disposed) CommandInvoked?.Invoke(command); });
118
}
119
120
internal void Dismiss()
121
{
122
pending = false;
123
IsMenuOpen = false;
124
if (active) { closing = true; TrayMenu.Hide(); }
125
AppWindow.Hide();
126
}
127
128
public void Dispose()
129
{
130
if (disposed) return;
131
Dismiss();
132
disposed = true;
133
ownerRoot.ActualThemeChanged -= OwnerThemeChanged;
134
Activated -= Window_Activated;
135
Close();
136
}
137
138
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(nint window);
139
[DllImport("user32.dll")] private static extern nint GetForegroundWindow();
140
[DllImport("user32.dll")] private static extern nint GetAncestor(nint window, uint flags);
141
}
@@ -0,0 +1,171 @@
1
using System.Runtime.InteropServices;
2
using System.Text;
3
using Microsoft.UI.Composition.SystemBackdrops;
4
using Microsoft.UI.Windowing;
5
using Microsoft.UI.Xaml.Media;
6
using Windows.Graphics;
7
using Windows.System;
8
using XFEWorkflow.Controls;
9
using XFEWorkflow.Infrastructure.Storage;
10
using XFEWorkflow.Plugins.Vision;
11
12
namespace XFEWorkflow.Services;
13
14
internal static class TrayMenuSmokeTest
15
{
16
internal static async Task ThemesAsync(string output)
17
{
18
var owner = App.MainWindow; var root = (FrameworkElement)owner.Content;
19
var previousTheme = root.RequestedTheme;
20
var tray = owner.Tray!;
21
var area = DisplayArea.GetFromWindowId(owner.AppWindow.Id, DisplayAreaFallback.Nearest).WorkArea;
22
var point = new PointInt32(area.X + area.Width / 2, area.Y + area.Height / 2);
23
try
24
{
25
root.RequestedTheme = ElementTheme.Dark;
26
owner.AppWindow.Hide();
27
tray.ShowContextMenu(point);
28
var menu = tray.ContextMenu!;
29
await WaitAsync(() => menu.IsMenuOpen && menu.Presenter is { ActualWidth: > 100 });
30
if (owner.AppWindow.IsVisible || menu.AppWindow.IsShownInSwitchers) throw new InvalidOperationException("托盘菜单显示了主窗口或任务栏按钮");
31
if (MicaController.IsSupported() && menu.Menu.SystemBackdrop is not MicaBackdrop) throw new InvalidOperationException("菜单未使用真实 Mica 背景");
32
if (menu.Menu.ShouldConstrainToRootBounds || menu.Menu.Items.OfType<MenuFlyoutItem>().Count() != 4) throw new InvalidOperationException("WinUI 菜单结构错误");
33
if (menu.Presenter!.ActualTheme != ElementTheme.Dark) throw new InvalidOperationException("托盘菜单未使用深色主题");
34
var dark = await CaptureAsync(menu, Path.Combine(output, "tray-menu-dark.png"));
35
// The owner's theme must propagate even while it is hidden and
36
// the native tray popup is already open.
37
root.RequestedTheme = ElementTheme.Light;
38
await WaitAsync(() => menu.IsMenuOpen && menu.Presenter?.ActualTheme == ElementTheme.Light);
39
var light = await CaptureAsync(menu, Path.Combine(output, "tray-menu-light.png"));
40
await AtomicJson.WriteAsync(Path.Combine(output, "tray-menu-material.json"), new { dark, light, micaSupported = MicaController.IsSupported() });
41
if (light.Brightness - dark.Brightness < 60) throw new InvalidOperationException($"云母背景未随主题切换:dark={dark.Brightness}, light={light.Brightness}");
42
menu.Activate(); UiSmokeTest.SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(menu));
43
UiSmokeTest.PressKey(VirtualKey.Escape, window: menu);
44
await WaitAsync(() => !menu.IsMenuOpen && !menu.AppWindow.IsVisible);
45
if (owner.AppWindow.IsVisible) throw new InvalidOperationException("关闭托盘菜单后主窗口意外出现");
46
foreach (var corner in new[] { new PointInt32(area.X, area.Y), new(area.X + area.Width - 1, area.Y), new(area.X, area.Y + area.Height - 1), new(area.X + area.Width - 1, area.Y + area.Height - 1) })
47
{
48
tray.ShowContextMenu(corner);
49
await WaitAsync(() => menu.IsMenuOpen && menu.Presenter is { ActualWidth: > 100 });
50
await Task.Delay(200);
51
var bounds = PopupBounds(menu);
52
var presenter = menu.Presenter!; var scale = presenter.XamlRoot.RasterizationScale;
53
// The external Fluent shadow belongs to the popup HWND and
54
// may cross the work-area edge; the menu itself must fit.
55
var shadowX = Math.Max(0, (bounds.Right - bounds.Left - presenter.ActualWidth * scale) / 2) + 2;
56
var shadowY = Math.Max(0, bounds.Bottom - bounds.Top - presenter.ActualHeight * scale) + 2;
57
if (bounds.Left < area.X - shadowX || bounds.Top < area.Y - shadowY || bounds.Right > area.X + area.Width + shadowX || bounds.Bottom > area.Y + area.Height + shadowY)
58
throw new InvalidOperationException($"托盘菜单超出显示器工作区:{bounds.Left},{bounds.Top},{bounds.Right},{bounds.Bottom}");
59
menu.Dismiss(); await Task.Delay(80);
60
}
61
tray.ShowContextMenu(point); await WaitAsync(() => menu.IsMenuOpen);
62
for (var i = 0; i < 6; i++) tray.ShowContextMenu(point);
63
await WaitAsync(() => menu.IsMenuOpen && menu.Presenter is { ActualWidth: > 100 });
64
tray.Show();
65
await WaitAsync(() => !menu.IsMenuOpen && !menu.AppWindow.IsVisible && owner.AppWindow.IsVisible);
66
}
67
finally { tray.ContextMenu?.Dismiss(); root.RequestedTheme = previousTheme; tray.Show(); }
68
}
69
70
internal static async Task CommandsAsync()
71
{
72
var owner = App.MainWindow; var tray = owner.Tray!; var scheduler = AppServices.Current.Scheduler;
73
var area = DisplayArea.GetFromWindowId(owner.AppWindow.Id, DisplayAreaFallback.Nearest).WorkArea;
74
var point = new PointInt32(area.X + area.Width / 2, area.Y + area.Height / 2);
75
var wasSuspended = scheduler.IsSuspended;
76
async Task<TrayMenuWindow> Open()
77
{
78
tray.ShowContextMenu(point);
79
await WaitAsync(() => tray.ContextMenu?.IsMenuOpen == true && tray.ContextMenu.Presenter is { ActualWidth: > 100 });
80
return tray.ContextMenu!;
81
}
82
async Task Invoke(TrayMenuWindow menu, string command)
83
{
84
var item = menu.Menu.Items.OfType<MenuFlyoutItem>().Single(x => x.Tag?.ToString() == command);
85
if (!item.IsEnabled || !item.Focus(FocusState.Keyboard)) throw new InvalidOperationException("菜单命令无法聚焦:" + command);
86
UiSmokeTest.PressKey(VirtualKey.Enter, window: menu);
87
await WaitAsync(() => !menu.IsMenuOpen && !menu.AppWindow.IsVisible);
88
}
89
try
90
{
91
await scheduler.ResumeAsync(); owner.AppWindow.Hide();
92
var menu = await Open();
93
if (menu.Menu.Items.OfType<MenuFlyoutItem>().Single(x => x.Tag?.ToString() == "Resume").IsEnabled) throw new InvalidOperationException("正常调度时恢复命令应禁用");
94
var flow = Core.Runtime.WorkflowTemplates.CreateGraph();
95
flow.Graph.Nodes.Insert(1, new Abstractions.NodeDefinition { CapabilityId = "core.delay", Parameters = new() { ["milliseconds"] = 20000 } });
96
flow.Graph.Edges.Clear();
97
for (var i = 0; i < flow.Graph.Nodes.Count - 1; i++) flow.Graph.Edges.Add(new() { FromNode = flow.Graph.Nodes[i].Id, ToNode = flow.Graph.Nodes[i + 1].Id });
98
var running = AppServices.Current.Runs.RunAsync(flow);
99
await WaitAsync(() => AppServices.Current.Runs.IsRunning(flow.Id));
100
await Invoke(menu, "EmergencyStop");
101
await WaitAsync(() => scheduler.IsSuspended);
102
var stopped = await running.WaitAsync(TimeSpan.FromSeconds(8));
103
if (stopped.Status != Abstractions.RunStatus.Cancelled) throw new InvalidOperationException("托盘紧急停止未取消 Runner:" + stopped.Status);
104
if (owner.AppWindow.IsVisible) throw new InvalidOperationException("紧急停止打开了主窗口");
105
menu = await Open(); await Invoke(menu, "Resume"); await WaitAsync(() => !scheduler.IsSuspended);
106
if (owner.AppWindow.IsVisible) throw new InvalidOperationException("恢复调度打开了主窗口");
107
menu = await Open(); await Invoke(menu, "Open"); await WaitAsync(() => owner.AppWindow.IsVisible);
108
// Deactivation is the native light-dismiss path, including a
109
// click on another window. Use our own window to avoid external apps.
110
menu = await Open(); owner.Activate(); UiSmokeTest.SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(owner));
111
await WaitAsync(() => !menu.IsMenuOpen && !menu.AppWindow.IsVisible);
112
}
113
finally { tray.ContextMenu?.Dismiss(); if (wasSuspended) scheduler.Suspend(); else await scheduler.ResumeAsync(); tray.Show(); }
114
}
115
116
private static async Task WaitAsync(Func<bool> condition)
117
{
118
for (var i = 0; i < 60; i++) { if (condition()) return; await Task.Delay(50); }
119
throw new InvalidOperationException("托盘 WinUI 菜单状态未按预期变化");
120
}
121
122
private sealed record MaterialSample(int Brightness, int Width, int Height);
123
private static async Task<MaterialSample> CaptureAsync(TrayMenuWindow menu, string path)
124
{
125
menu.Activate(); UiSmokeTest.SetForegroundWindow(WinRT.Interop.WindowNative.GetWindowHandle(menu));
126
await Task.Delay(250);
127
var rect = PopupBounds(menu);
128
var foreground = GetForegroundWindow(); GetWindowThreadProcessId(foreground, out var foregroundProcess);
129
if (!menu.IsMenuOpen || foregroundProcess != Environment.ProcessId)
130
{
131
var name = new StringBuilder(256); GetClassName(foreground, name, name.Capacity);
132
throw new InvalidOperationException($"托盘验收窗口失去焦点,已停止截图:open={menu.IsMenuOpen}, foreground={name}, process={foregroundProcess}");
133
}
134
GetWindowThreadProcessId(WindowFromPoint(new() { X = (rect.Left + rect.Right) / 2, Y = (rect.Top + rect.Bottom) / 2 }), out var process);
135
if (process != Environment.ProcessId) throw new InvalidOperationException("托盘菜单被其他窗口遮挡,已停止截图");
136
// Capture only the verified native popup belonging to this test
137
// process; RenderTargetBitmap does not include a system backdrop.
138
using var frame = ScreenCapture.Capture(new(rect.Left, rect.Top, rect.Right - rect.Left, rect.Bottom - rect.Top));
139
frame.Bitmap.Save(path, System.Drawing.Imaging.ImageFormat.Png);
140
var pixel = frame.Bitmap.GetPixel(frame.Bitmap.Width - 64, frame.Bitmap.Height / 2);
141
return new((pixel.R + pixel.G + pixel.B) / 3, frame.Bitmap.Width, frame.Bitmap.Height);
142
}
143
144
145
private static NativeRect PopupBounds(TrayMenuWindow menu)
146
{
147
var anchor = WinRT.Interop.WindowNative.GetWindowHandle(menu);
148
var thread = GetWindowThreadProcessId(anchor, out _); var matches = new List<NativeRect>(); var trace = new List<string>();
149
EnumThreadWindows(thread, (handle, _) =>
150
{
151
if (!IsWindowVisible(handle)) return true;
152
GetWindowRect(handle, out var rect); var name = new StringBuilder(256); GetClassName(handle, name, name.Capacity);
153
GetWindowThreadProcessId(handle, out var process);
154
trace.Add($"{name}: {rect.Left},{rect.Top},{rect.Right},{rect.Bottom}");
155
if (process == Environment.ProcessId && handle != anchor && handle != WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow)
156
&& rect.Right - rect.Left > 100 && rect.Bottom - rect.Top > 50 && name.ToString().Contains("Popup", StringComparison.OrdinalIgnoreCase)) matches.Add(rect);
157
return true;
158
}, 0);
159
return matches.Count == 1 ? matches[0] : throw new InvalidOperationException("未找到唯一的原生 WinUI 菜单窗口:" + string.Join("; ", trace));
160
}
161
private delegate bool EnumWindowProc(nint window, nint data);
162
[StructLayout(LayoutKind.Sequential)] private struct NativeRect { public int Left, Top, Right, Bottom; }
163
[StructLayout(LayoutKind.Sequential)] private struct NativePoint { public int X, Y; }
164
[DllImport("user32.dll")] private static extern nint GetForegroundWindow();
165
[DllImport("user32.dll")] private static extern nint WindowFromPoint(NativePoint point);
166
[DllImport("user32.dll")] private static extern bool EnumThreadWindows(uint thread, EnumWindowProc callback, nint data);
167
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(nint window, out uint process);
168
[DllImport("user32.dll")] private static extern bool IsWindowVisible(nint window);
169
[DllImport("user32.dll")] private static extern bool GetWindowRect(nint window, out NativeRect rect);
170
[DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetClassName(nint window, StringBuilder name, int size);
171
}
@@ -1,5 +1,7 @@
1
1
using System.Runtime.InteropServices;
2
2
using Microsoft.UI.Windowing;
3
using XFEWorkflow.Controls;
4
using Windows.Graphics;
3
5
4
6
namespace XFEWorkflow.Services;
5
7
@@ -11,6 +13,7 @@ public sealed class TrayService : IDisposable
11
13
private readonly MainWindow owner;
12
14
private readonly SubclassProc procedure;
13
15
private NotifyIconData icon;
16
private TrayMenuWindow? menu;
14
17
private bool quitting, disposed;
15
18
public TrayService(MainWindow owner)
16
19
{
@@ -33,21 +36,42 @@ public sealed class TrayService : IDisposable
33
36
if ((uint)lParam is 0x0203 or 0x0202) Show();
34
37
else if ((uint)lParam is 0x0205 or 0x007B)
35
38
{
36
GetCursorPos(out var point); var menu = CreatePopupMenu();
37
try
38
{
39
AppendMenu(menu, 0, 1, "打开 XFEWorkflow"); AppendMenu(menu, 0, 2, "紧急停止全部任务"); AppendMenu(menu, 0, 4, "恢复自动调度"); AppendMenu(menu, 0, 3, "退出");
40
SetForegroundWindow(window);
41
var selected = TrackPopupMenu(menu, 0x100 | 2, point.X, point.Y, 0, window, 0);
42
if (selected == 1) Show(); else if (selected == 2) EmergencyStop(); else if (selected == 3) _ = ExitAsync(); else if (selected == 4) _ = AppServices.Current.Scheduler.ResumeAsync();
43
}
44
finally { DestroyMenu(menu); }
39
GetCursorPos(out var point);
40
owner.DispatcherQueue.TryEnqueue(() => ShowContextMenu(new(point.X, point.Y)));
45
41
}
46
42
return 0;
47
43
}
48
44
return DefSubclassProc(hwnd, message, wParam, lParam);
49
45
}
50
public void Show() { owner.AppWindow.Show(); owner.Activate(); SetForegroundWindow(window); }
46
internal TrayMenuWindow? ContextMenu => menu;
47
internal void ShowContextMenu(PointInt32 point)
48
{
49
if (disposed || quitting) return;
50
if (menu is null)
51
{
52
menu = new((FrameworkElement)owner.Content);
53
menu.CommandInvoked += InvokeCommand;
54
}
55
menu.ShowAt(point, AppServices.Current.Scheduler.IsSuspended);
56
}
57
private void InvokeCommand(TrayCommand command)
58
{
59
if (disposed || quitting) return;
60
switch (command)
61
{
62
case TrayCommand.Open: Show(); break;
63
case TrayCommand.EmergencyStop: EmergencyStop(); break;
64
case TrayCommand.Resume: _ = ResumeAsync(); break;
65
case TrayCommand.Exit: _ = ExitAsync(); break;
66
}
67
}
68
private async Task ResumeAsync()
69
{
70
try { await AppServices.Current.Scheduler.ResumeAsync(); }
71
catch (OperationCanceledException) when (quitting) { }
72
catch (Exception ex) { Console.WriteLine("[ERROR]恢复自动调度:" + ex.Message); }
73
}
74
public void Show() { menu?.Dismiss(); owner.AppWindow.Show(); owner.Activate(); SetForegroundWindow(window); }
51
75
public void EmergencyStop()
52
76
{
53
77
AppServices.Current.Scheduler.Suspend();
@@ -65,6 +89,7 @@ public sealed class TrayService : IDisposable
65
89
public void Dispose()
66
90
{
67
91
if (disposed) return; disposed = true;
92
if (menu is not null) { menu.CommandInvoked -= InvokeCommand; menu.Dispose(); menu = null; }
68
93
owner.AppWindow.Closing -= Closing; UnregisterHotKey(window, HotKeyId);
69
94
ShellNotifyIcon(2, ref icon); if (icon.Icon != 0) DestroyIcon(icon.Icon);
70
95
RemoveWindowSubclass(window, procedure, 1);
@@ -90,9 +115,5 @@ public sealed class TrayService : IDisposable
90
115
[DllImport("user32.dll")] private static extern bool UnregisterHotKey(nint hwnd, int id);
91
116
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(nint hwnd);
92
117
[DllImport("user32.dll")] private static extern bool GetCursorPos(out Point point);
93
[DllImport("user32.dll")] private static extern nint CreatePopupMenu();
94
[DllImport("user32.dll", CharSet = CharSet.Unicode, EntryPoint = "AppendMenuW")] private static extern bool AppendMenu(nint menu, uint flags, nuint id, string text);
95
[DllImport("user32.dll")] private static extern uint TrackPopupMenu(nint menu, uint flags, int x, int y, int reserved, nint hwnd, nint rect);
96
[DllImport("user32.dll")] private static extern bool DestroyMenu(nint menu);
97
118
[DllImport("user32.dll")] private static extern bool DestroyIcon(nint icon);
98
119
}
@@ -44,6 +44,8 @@ internal static class UiSmokeTest
44
44
await Check("vision-plugin-form-and-offline-ocr-through-runner", () => MainPage.Current!.SmokeVisionAsync(output));
45
45
await Check("image-template-preview-reselect-similarity-export-and-runner", () => MainPage.Current!.SmokeImageTemplatesAsync(output));
46
46
await Check("region-picker-mouse-drag-screen-ocr-point-and-cancel", () => Controls.ScreenRegionPicker.VerifyAsync(Path.Combine(output, "screen-region-picker.png")));
47
await Check("tray-native-mica-light-dark-live-theme-and-dismissal", () => TrayMenuSmokeTest.ThemesAsync(output));
48
await Check("tray-hidden-window-open-emergency-stop-and-resume", () => TrayMenuSmokeTest.CommandsAsync());
47
49
var listener = new HttpListener(); var portSource = new TcpListener(IPAddress.Loopback, 0); portSource.Start();
48
50
var port = ((IPEndPoint)portSource.LocalEndpoint).Port; portSource.Stop();
49
51
var url = $"http://localhost:{port}/"; listener.Prefixes.Add(url); listener.Prefixes.Add($"http://127.0.0.1:{port}/"); listener.Start();
@@ -236,9 +238,9 @@ internal static class UiSmokeTest
236
238
for (var i = 0; i < Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChildrenCount(element); i++)
237
239
foreach (var child in Descendants(Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChild(element, i))) yield return child;
238
240
}
239
internal static void PressKey(Windows.System.VirtualKey key, bool control = false)
241
internal static void PressKey(Windows.System.VirtualKey key, bool control = false, Window? window = null)
240
242
{
241
EnsureForeground();
243
EnsureForeground(window);
242
244
InputPacket Packet(Windows.System.VirtualKey value, bool up) => new() { Type = 1, Data = new() { Keyboard = new() { Key = (ushort)value, Flags = up ? 2u : 0 } } };
243
245
var packets = control ? new[] { Packet(Windows.System.VirtualKey.Control, false), Packet(key, false), Packet(key, true), Packet(Windows.System.VirtualKey.Control, true) }
244
246
: new[] { Packet(key, false), Packet(key, true) };
@@ -249,12 +251,14 @@ internal static class UiSmokeTest
249
251
throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
250
252
}
251
253
}
252
private static nint EnsureForeground()
254
private static nint EnsureForeground(Window? window = null)
253
255
{
254
var handle = WinRT.Interop.WindowNative.GetWindowHandle(App.MainWindow);
255
if (GetForegroundWindow() != handle) throw new InvalidOperationException("验收窗口失去焦点,已停止输入");
256
var handle = WinRT.Interop.WindowNative.GetWindowHandle(window ?? App.MainWindow);
257
var foreground = GetForegroundWindow();
258
if (foreground != handle && GetAncestor(foreground, 3) != handle) throw new InvalidOperationException("验收窗口失去焦点,已停止输入");
256
259
return handle;
257
260
}
261
[DllImport("user32.dll")] private static extern nint GetAncestor(nint window, uint flags);
258
262
private static (int X, int Y, double Scale) ElementCenter(FrameworkElement element)
259
263
{
260
264
var handle = EnsureForeground(); var root = (FrameworkElement)App.MainWindow.Content;
@@ -26,6 +26,8 @@ UI 验收由 `XFEWorkflow.exe --ui-test=绝对输出目录` 驱动,使用独
26
26
27
27
最近一次测试报告在本机 `.artifacts` 中。报告的 passed 字段与每项检查结果是验收依据,代码中的测试名称不代表已通过。
28
28
29
托盘菜单新增原生 WinUI / Mica 检查,验证实时浅深色切换、隐藏窗口、四角定位、连续右键、Esc 和失去焦点关闭,以及菜单紧急停止真实 Runner、恢复调度和打开主窗口。详情与截图见 [托盘菜单](tray.md)。
30
29
31
2026-10-11 Windows x64 Release 验证:构建零警告、零错误;81/81 项 XFE XUnit 测试通过(`.artifacts/palette-panels-tests-81`),22/22 项 UI 检查通过(`.artifacts/ui-node-library-full`,报告副本见 [完整 UI 报告](screenshots/node-library-ui-results.json))。OCR 模型、原生引擎、本地 C++ 运行库与许可包含在发布包和 `.xfeplugin` 中。
30
32
31
33
界面截图:[分类浏览(深色)](screenshots/node-library-categories-dark.png)、[搜索与收藏(浅色)](screenshots/node-library-search-light.png)、[可调整面板布局](screenshots/workflow-resizable-panels.png)。
二进制文件已变更,无法进行逐行预览。
二进制文件已变更,无法进行逐行预览。
@@ -0,0 +1,18 @@
1
{
2
"passed": true,
3
"checks": [
4
{
5
"name": "tray-native-mica-light-dark-live-theme-and-dismissal",
6
"passed": true
7
},
8
{
9
"name": "tray-hidden-window-open-emergency-stop-and-resume",
10
"passed": true
11
},
12
{
13
"name": "account-avatar-and-navigation-synchronization",
14
"passed": true
15
}
16
],
17
"errors": []
18
}
@@ -0,0 +1,16 @@
1
# 托盘菜单
2
3
右键菜单使用原生 WinUI `MenuFlyout` 与 `MicaBackdrop`,提供 Fluent 图标、圆角、阴影、悬停与键盘焦点样式。[Mica 背景 API](https://learn.microsoft.com/en-us/windows/apps/develop/ui/system-backdrops) 由系统管理材料和回退策略;不支持 Mica 时保留原生菜单背景,高对比度使用系统颜色。
4
5
菜单跟随应用实际主题,支持浅色、深色和跟随系统。主窗口隐藏时仍可打开;菜单打开期间切换应用主题,也会更新菜单文字和材料。菜单使用独立、无边框的定位窗口,不显示任务栏按钮;位置使用物理屏幕坐标,WinUI 处理显示器 DPI、边缘避让与菜单阴影。
6
7
- 打开 XFEWorkflow:恢复并激活主窗口。单击或双击托盘图标仍可打开窗口。
8
- 紧急停止全部任务:暂停自动调度、取消 Runner、终止框选并释放桌面输入。全局 Ctrl+Alt+F12 继续可用。
9
- 恢复自动调度:仅在调度暂停时可用,重新加载触发器后恢复。
10
- 退出:停止任务、保存配置并清理浏览器、托盘和菜单窗口。
11
12
Esc、点击其他窗口或执行命令会关闭菜单。连续右键打开时等待上一次关闭完成,避免关闭动画的旧事件隐藏新菜单。
13
14
2026-10-11 Windows x64 验证:Release 构建零警告、零错误,新增两项 UI 检查通过,覆盖真实浅深色材料截图、打开时实时切换主题、隐藏窗口、屏幕四角、连续打开、Esc、失去焦点、正常恢复主窗口,以及通过菜单取消真实 Runner 和恢复调度。截图只采集测试进程自身的可见菜单,遮挡或失去前台时停止采集。
15
16
查看:[深色菜单](screenshots/tray-menu-dark.png)、[浅色菜单](screenshots/tray-menu-light.png)、[验证报告](screenshots/tray-menu-ui-results.json)。