XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEWorkflow

【WinUI】XFE基于windows的工作流

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFEWorkflow

将托盘菜单改为支持浅深主题的 WinUI 云母菜单

8548b6a
XFE工作室室长 <mail@xfegzs.com>
提交于

代码差异

10 个文件 +439 -19
Added XFEWorkflow/XFEWorkflow/Controls/TrayMenuWindow.xaml +47 -0
@@ -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="&#xEF90;"/></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="&#xE71A;"/></MenuFlyoutItem.Icon>
36 </MenuFlyoutItem>
37 <MenuFlyoutItem x:Name="ResumeItem" Text="恢复自动调度" Tag="Resume" Click="Command_Click">
38 <MenuFlyoutItem.Icon><FontIcon Glyph="&#xE768;"/></MenuFlyoutItem.Icon>
39 </MenuFlyoutItem>
40 <MenuFlyoutSeparator/>
41 <MenuFlyoutItem x:Name="ExitItem" Text="退出" Tag="Exit" Click="Command_Click">
42 <MenuFlyoutItem.Icon><FontIcon Glyph="&#xE7E8;"/></MenuFlyoutItem.Icon>
43 </MenuFlyoutItem>
44 </MenuFlyout>
45 </Grid.ContextFlyout>
46 </Grid>
47 </Window>
Added XFEWorkflow/XFEWorkflow/Controls/TrayMenuWindow.xaml.cs +141 -0
@@ -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 }
Added XFEWorkflow/XFEWorkflow/Services/TrayMenuSmokeTest.cs +171 -0
@@ -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 }
Modified XFEWorkflow/XFEWorkflow/Services/TrayService.cs +35 -14
@@ -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 }
Modified XFEWorkflow/XFEWorkflow/Services/UiSmokeTest.cs +9 -5
@@ -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;
Modified docs/acceptance.md +2 -0
@@ -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)。
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
@@ -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 }
Added docs/tray.md +16 -0
@@ -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)。