diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 223d366a..fe86b93e 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -192,6 +192,7 @@ jobs: path: | artifacts/focalet-capture-win-x64.zip artifacts/capture-paste-acceptance.png + artifacts/capture-tray-menu*.png artifacts/capture-native-clipboard.png artifacts/capture-browser-paste.png artifacts/capture-electron-*.png diff --git a/docs/capture-tool.md b/docs/capture-tool.md index 67a316d8..5c507c38 100644 --- a/docs/capture-tool.md +++ b/docs/capture-tool.md @@ -50,11 +50,19 @@ automatically. Known protected fields are excluded. Destination accessibility checks inspect identity and state, not input text. The receiving app controls whether it accepts images and where attachments appear. +On Windows, attaching an image can briefly move focus to the containing page. +Capture waits up to 1.5 seconds for the original input to regain focus, without +moving the caret or sending the image again. A different input, window or replaced +clipboard still stops the sequence. Presses while pasting do not queue another +batch; briefly held shortcut keys pause the current sequence until released. + ## Menu and clipboard The system tray, menu bar or GNOME panel provides **Capture**, **Copy last batch**, **Copy text**, preferences, and **Quit**. Text-only and slower-image preferences are in the menu on Windows/Mac and **Preferences** on Ubuntu. +Windows uses the same smooth rounded menu design as Desktop, with keyboard +navigation and native accessible actions and checkboxes. Each automatic image step offers only image formats: PNG/DIB/RTF on Windows, PNG/TIFF on Mac, and PNG on Ubuntu. GNOME owns Ubuntu clipboard transfers diff --git a/src/Focalet.CaptureTool/CapturePasteSequence.cs b/src/Focalet.CaptureTool/CapturePasteSequence.cs index 30bcc424..c7f1e381 100644 --- a/src/Focalet.CaptureTool/CapturePasteSequence.cs +++ b/src/Focalet.CaptureTool/CapturePasteSequence.cs @@ -21,15 +21,14 @@ public static async Task PasteAsync(CaptureClipboardBatch ba { foreach (var image in textOnly ? new[] { false } : new[] { true, false }) { - if (!await target.IsInputCurrentAsync() || !CapturePasteTarget.ModifiersReleased) - return new(sent, unreadImages, "The input or pressed keys changed."); - if (sent > 0 && !clipboard.OwnsClipboard) - return new(sent, unreadImages, "Another application replaced the clipboard."); + if (await WaitForInputAsync(target, clipboard, sent > 0) is { } notReady) + return new(sent, unreadImages, notReady); if (image) clipboard.SetImage(batch.Items[index]); else clipboard.SetText(batch.TextParts[index]); clipboard.Arm(); - if (!await target.IsInputCurrentAsync() || - !target.Paste(CapturePasteTarget.GetClipboardSequenceNumber(), clipboard.Handle)) + if (await WaitForInputAsync(target, clipboard, true) is { } interrupted) + return new(sent, unreadImages, interrupted); + if (!target.Paste(CapturePasteTarget.GetClipboardSequenceNumber(), clipboard.Handle)) return new(sent, unreadImages, "Paste could not be dispatched. It will not be retried."); sent++; @@ -41,11 +40,21 @@ public static async Task PasteAsync(CaptureClipboardBatch ba var minimumWait = image ? (slowerImages ? 3000 : 500) : 150; var settleAfterRead = image ? (slowerImages ? 600 : 200) : 75; var wait = Stopwatch.StartNew(); + Stopwatch? heldKeys = null; while (wait.ElapsedMilliseconds < (image ? 5000 : 3000)) { await Task.Delay(50); - if (!clipboard.OwnsClipboard || !target.IsCurrent() || !CapturePasteTarget.ModifiersReleased) - return new(sent, unreadImages, "Focus, keys or clipboard changed during paste."); + if (!clipboard.OwnsClipboard) + return new(sent, unreadImages, "Another application replaced the clipboard."); + if (!target.IsCurrent()) return new(sent, unreadImages, "The destination window changed during paste."); + if (!CapturePasteTarget.ModifiersReleased) + { + heldKeys ??= Stopwatch.StartNew(); + if (heldKeys.ElapsedMilliseconds >= 1500) + return new(sent, unreadImages, "The shortcut keys were not released."); + continue; + } + heldKeys = null; if (clipboard.Read && wait.ElapsedMilliseconds >= minimumWait && clipboard.MillisecondsSinceRead >= settleAfterRead) break; } @@ -60,6 +69,24 @@ public static async Task PasteAsync(CaptureClipboardBatch ba return new(sent, unreadImages); } + private static async Task WaitForInputAsync(CapturePasteTarget target, PasteClipboard clipboard, bool checkClipboard) + { + var wait = Stopwatch.StartNew(); + while (true) + { + if (!target.IsCurrent()) return "The destination window changed during paste."; + if (checkClipboard && !clipboard.OwnsClipboard) return "Another application replaced the clipboard."; + var state = await target.InputStateAsync(); + if (!target.IsCurrent()) return "The destination window changed during paste."; + if (checkClipboard && !clipboard.OwnsClipboard) return "Another application replaced the clipboard."; + if (state == CaptureInputState.Changed) return "The focused input changed during paste."; + if (state == CaptureInputState.Current && CapturePasteTarget.ModifiersReleased) return null; + if (wait.ElapsedMilliseconds >= 1500) + return state == CaptureInputState.Settling ? "The selected input did not regain focus." : "The shortcut keys were not released."; + await Task.Delay(25); + } + } + // Win32 delayed rendering also works with apps using Electron's native // clipboard API rather than the browser paste event's DataTransfer object. private sealed class PasteClipboard : NativeWindow, IDisposable diff --git a/src/Focalet.CaptureTool/CapturePasteTarget.cs b/src/Focalet.CaptureTool/CapturePasteTarget.cs index 867bede0..33325fde 100644 --- a/src/Focalet.CaptureTool/CapturePasteTarget.cs +++ b/src/Focalet.CaptureTool/CapturePasteTarget.cs @@ -9,6 +9,7 @@ namespace Focalet.CaptureTool; internal enum CaptureInputKind { Unknown, Input, Protected } +internal enum CaptureInputState { Current, Settling, Changed } internal sealed record CaptureInputObservation(CapturePasteTarget Target, CaptureInputKind Kind); /// Identity of the input focused when the user invokes paste. @@ -16,6 +17,7 @@ internal sealed record CapturePasteTarget(nint Window, nint Focus, uint ProcessI : WindowFocus(Window, Focus, ProcessId, ProcessStarted) { private AutomationElement? InputElement { get; init; } + private UIA3Automation? InputAutomation { get; init; } public string Description => NativeCaptureWindow.Title(Window); public new static CapturePasteTarget? Remember() => RememberWindow() is { Focus: not 0 } target ? target : null; @@ -55,7 +57,7 @@ internal static CaptureInputObservation ObserveInput(UIA3Automation automation, if (!target.IsCurrent() || !element.Properties.HasKeyboardFocus.ValueOrDefault) return Unknown(); // The user already chose the caret. Keep identity for interruption checks, // without selecting UIA output ranges or moving focus. - return new(target with { InputElement = element }, CaptureInputKind.Input); + return new(target with { InputElement = element, InputAutomation = automation }, CaptureInputKind.Input); } internal static bool IsTerminalControl(string windowClass, string? inputClass) => @@ -71,13 +73,37 @@ private static string WindowClass(nint window) internal bool ContainsWindow(nint window) => window == Window || GetAncestor(window, 2) == Window; public async Task IsInputCurrentAsync() + => await InputStateAsync() == CaptureInputState.Current; + + internal async Task InputStateAsync() { - if (!IsCurrent()) return false; - if (InputElement is null) return true; + if (!IsCurrent()) return CaptureInputState.Changed; + if (InputElement is null) return CaptureInputState.Current; return await Task.Run(() => { - try { return InputElement.Properties.HasKeyboardFocus.ValueOrDefault && IsCurrent(); } - catch (Exception error) when (error is not OutOfMemoryException) { return false; } + try + { + if (InputElement.Properties.HasKeyboardFocus.ValueOrDefault) + return IsCurrent() ? CaptureInputState.Current : CaptureInputState.Changed; + var focused = InputAutomation?.FocusedElement(); + if (!IsCurrent()) return CaptureInputState.Changed; + if (focused is null) return CaptureInputState.Settling; + var process = focused.Properties.ProcessId.ValueOrDefault; + if (process != ProcessId && (Focus == 0 || process != WindowProcess(Focus))) return CaptureInputState.Changed; + var window = focused.Properties.NativeWindowHandle.ValueOrDefault; + if (window != 0 && !ContainsWindow(window)) return CaptureInputState.Changed; + if (InputAutomation?.Compare(InputElement, focused) == true) return CaptureInputState.Settling; + if (focused.Properties.IsPassword.ValueOrDefault || + focused.Patterns.Value.PatternOrDefault?.IsReadOnly.ValueOrDefault == false) + return CaptureInputState.Changed; + // An attachment can briefly move accessibility focus to the + // containing page while the SAME editor is being updated. + // Never bind to another editor or move the user's caret. + var type = focused.Properties.ControlType.ValueOrDefault; + return type == ControlType.Document || type == ControlType.Pane || type == ControlType.Window + ? CaptureInputState.Settling : CaptureInputState.Changed; + } + catch (Exception error) when (error is not OutOfMemoryException) { return CaptureInputState.Changed; } }); } diff --git a/src/Focalet.CaptureTool/CapturePasteTool.cs b/src/Focalet.CaptureTool/CapturePasteTool.cs index 2990e53c..1946eed6 100644 --- a/src/Focalet.CaptureTool/CapturePasteTool.cs +++ b/src/Focalet.CaptureTool/CapturePasteTool.cs @@ -47,16 +47,15 @@ internal sealed class CaptureContext : ApplicationContext { private readonly HotkeyWindow hotkey; private readonly NotifyIcon tray; - private readonly ContextMenuStrip menu; + private readonly CaptureTrayMenu menu; private readonly Icon icon; private readonly Func select; private readonly Action? report; private readonly bool includeOwnProcess; private CaptureClipboardBatch? lastBatch; private bool busy; - private readonly ToolStripMenuItem textOnly; - private readonly ToolStripMenuItem slowerImages; - private readonly ToolStripMenuItem batchStatus; + private bool textOnly; + private bool slowerImages; internal bool Busy => busy; internal bool HasBatch => lastBatch is not null; @@ -69,23 +68,23 @@ public CaptureContext(Func? select = null, A ?? throw new InvalidOperationException("The Capture icon is missing."); icon = new Icon(iconStream); hotkey = new HotkeyWindow(Capture, Paste); - menu = new ContextMenuStrip(); - batchStatus = new ToolStripMenuItem("No capture ready") { Enabled = false }; - menu.Items.Add(batchStatus); - menu.Opening += (_, _) => batchStatus.Text = lastBatch is { } batch - ? $"{batch.Items.Count} regions ready · Alt+A to paste" : "No capture ready"; - menu.Items.Add("Capture · Shift+Alt+A", null, (_, _) => Capture()); - menu.Items.Add("Copy last batch", null, (_, _) => CopyLast()); - menu.Items.Add("Copy text", null, (_, _) => CopyLast(forceText: true)); - textOnly = new ToolStripMenuItem("Text only") { CheckOnClick = true }; - menu.Items.Add(textOnly); - slowerImages = new ToolStripMenuItem("Slower image paste") { CheckOnClick = true }; - menu.Items.Add(slowerImages); - menu.Items.Add(new ToolStripSeparator()); - menu.Items.Add("Quit", null, (_, _) => { if (!busy) ExitThread(); }); + menu = new CaptureTrayMenu( + new("No capture ready"), + new("Capture · Shift+Alt+A", Capture), + new("Copy last batch", () => CopyLast()), + new("Copy text", () => CopyLast(forceText: true)), + new("Text only", () => textOnly = !textOnly, () => textOnly), + new("Slower image paste", () => slowerImages = !slowerImages, () => slowerImages), + new("Quit", () => { if (!busy) ExitThread(); })); tray = new NotifyIcon { - Icon = icon, Text = "Focalet Capture · Shift+Alt+A capture · Alt+A paste", ContextMenuStrip = menu, Visible = true, + Icon = icon, Text = "Focalet Capture · Shift+Alt+A capture · Alt+A paste", Visible = true, + }; + tray.MouseUp += (_, args) => + { + if (args.Button is MouseButtons.Left or MouseButtons.Right) + menu.ShowAt(Cursor.Position, lastBatch is { } batch + ? $"{batch.Items.Count} regions ready · Alt+A to paste" : "No capture ready"); }; Notify("Ready", "Shift+Alt+A to capture. Then click the destination input and press Alt+A to paste."); } @@ -101,6 +100,7 @@ private async void Capture() { if (busy) return; busy = true; + menu.Hide(); try { if (!await WaitForKeys()) return; @@ -151,7 +151,7 @@ private async void Paste() if (!await target.IsInputCurrentAsync()) return; // Retain the capture for another deliberate Alt+A at its current // destination. Busy invocations are ignored, never queued or retried. - var result = await CapturePasteSequence.PasteAsync(batch, target, textOnly.Checked, slowerImages.Checked); + var result = await CapturePasteSequence.PasteAsync(batch, target, textOnly, slowerImages); if (result.StoppedBecause is { } reason) Notify("Paste stopped", reason + " Alt+A pastes the whole batch again; Copy text is also available."); } @@ -162,7 +162,7 @@ private async void Paste() private void CopyLast(bool forceText = false) { if (busy || lastBatch is null) return; - try { Clipboard.SetDataObject(ClipboardData(lastBatch, forceText || textOnly.Checked), true, 5, 80); } + try { Clipboard.SetDataObject(ClipboardData(lastBatch, forceText || textOnly), true, 5, 80); } catch (ExternalException) { Notify("Clipboard busy", "Try Copy last batch again."); } } diff --git a/src/Focalet.CaptureTool/CaptureTrayMenu.cs b/src/Focalet.CaptureTool/CaptureTrayMenu.cs new file mode 100644 index 00000000..afc7ca9f --- /dev/null +++ b/src/Focalet.CaptureTool/CaptureTrayMenu.cs @@ -0,0 +1,216 @@ +using System.Drawing.Imaging; +using System.Drawing.Text; +using System.Runtime.InteropServices; + +namespace Focalet.CaptureTool; + +internal sealed record CaptureTrayItem(string Text, Action? Invoke = null, Func? IsChecked = null); + +/// A native, keyboard-accessible popup matching Desktop's rounded tray surface. +internal sealed class CaptureTrayMenu : Form +{ + private readonly CaptureTrayItem[] items; + private readonly Control[] rows; + private Font? menuFont; + private int hovered = -1; + private int dpi = 96; + private bool painting; + private bool refreshingChecks; + + internal CaptureTrayMenu(params CaptureTrayItem[] items) + { + this.items = items; + FormBorderStyle = FormBorderStyle.None; + StartPosition = FormStartPosition.Manual; + AutoScaleMode = AutoScaleMode.None; + ShowInTaskbar = false; + TopMost = true; + Text = "Focalet Capture menu"; + rows = items.Select((item, index) => + { + Control row = item.Invoke is null ? new MenuLabel() : item.IsChecked is null + ? new MenuButton() : new MenuCheckBox(); + row.Text = item.Text; + row.AccessibleName = item.Text; + row.Enabled = item.Invoke is not null; + row.TabStop = item.Invoke is not null; + row.MouseEnter += (_, _) => { hovered = index; PaintSurface(); }; + row.MouseLeave += (_, _) => { hovered = -1; PaintSurface(); }; + row.GotFocus += (_, _) => PaintSurface(); + row.LostFocus += (_, _) => PaintSurface(); + if (row is CheckBox check) + check.CheckedChanged += (_, _) => + { + if (refreshingChecks || !Visible) return; + Hide(); item.Invoke?.Invoke(); + }; + else row.Click += (_, _) => { Hide(); item.Invoke?.Invoke(); }; + Controls.Add(row); + return row; + }).ToArray(); + Deactivate += (_, _) => Hide(); + } + + protected override CreateParams CreateParams + { + get { var value = base.CreateParams; value.ExStyle |= 0x80000 | 0x80; return value; } + } + + internal void ShowAt(Point pointer, string status) + { + rows[0].Text = rows[0].AccessibleName = status; + hovered = -1; + Location = pointer; + dpi = (int)GetDpiForWindow(Handle); + if (dpi == 0) dpi = 96; + if (menuFont is null || menuFont.Size != Scale(11)) + { + var previousFont = menuFont; + Font = menuFont = new Font("Segoe UI", Scale(11), FontStyle.Regular, GraphicsUnit.Pixel); + previousFont?.Dispose(); + } + var width = Math.Max(Scale(280), rows.Max(row => TextRenderer.MeasureText(row.Text, Font).Width) + Scale(56)); + Size = new Size(width, Scale(10 + items.Length * 30)); + refreshingChecks = true; + try + { + for (var i = 0; i < rows.Length; i++) + { + rows[i].Bounds = new Rectangle(0, Scale(5 + i * 30), width, Scale(30)); + if (rows[i] is CheckBox check) check.Checked = items[i].IsChecked?.Invoke() == true; + } + } + finally { refreshingChecks = false; } + var work = Screen.FromPoint(pointer).WorkingArea; + Location = new Point(Math.Clamp(pointer.X - Width, work.Left, Math.Max(work.Left, work.Right - Width)), + Math.Clamp(pointer.Y - Height, work.Top, Math.Max(work.Top, work.Bottom - Height))); + Show(); + Activate(); + rows.FirstOrDefault(row => row.Enabled)?.Focus(); + PaintSurface(); + } + + protected override bool ProcessCmdKey(ref Message message, Keys keyData) + { + if (keyData == Keys.Escape) { Hide(); return true; } + if (keyData is Keys.Down or Keys.Up or Keys.Tab or (Keys.Shift | Keys.Tab)) + { + var enabled = rows.Where(row => row.Enabled).ToArray(); + var current = Array.FindIndex(enabled, row => row.Focused); + var direction = keyData is Keys.Up or (Keys.Shift | Keys.Tab) ? -1 : 1; + if (enabled.Length > 0) enabled[(current + direction + enabled.Length) % enabled.Length].Focus(); + return true; + } + if (keyData == Keys.Enter) + { + var index = Array.FindIndex(rows, row => row.Focused && row.Enabled); + if (index >= 0) { Hide(); items[index].Invoke?.Invoke(); } + return true; + } + return base.ProcessCmdKey(ref message, keyData); + } + + protected override void OnPaintBackground(PaintEventArgs e) { } + protected override void OnPaint(PaintEventArgs e) => PaintSurface(); + + private int Scale(int value) => (int)Math.Round(value * dpi / 96.0); + + private void PaintSurface() + { + if (painting || Disposing || IsDisposed || !Visible || !IsHandleCreated || Width <= 0 || Height <= 0) return; + painting = true; + try + { + using var bitmap = new Bitmap(Width, Height, PixelFormat.Format32bppPArgb); + using (var graphics = Graphics.FromImage(bitmap)) + using (var foreground = new SolidBrush(Color.FromArgb(230, 230, 230))) + using (var muted = new SolidBrush(Color.FromArgb(160, 160, 160))) + using (var hover = new SolidBrush(Color.FromArgb(60, 60, 60))) + using (var format = new StringFormat { LineAlignment = StringAlignment.Center }) + { + graphics.Clear(Color.FromArgb(40, 40, 40)); + graphics.TextRenderingHint = TextRenderingHint.AntiAliasGridFit; + for (var i = 0; i < rows.Length; i++) + { + var row = rows[i]; + if (row.Enabled && (hovered >= 0 ? hovered == i : row.Focused)) graphics.FillRectangle(hover, row.Bounds); + if (row is CheckBox { Checked: true }) + graphics.DrawString("✓", Font, foreground, new Rectangle(Scale(10), row.Top, Scale(20), row.Height), format); + graphics.DrawString(row.Text, Font, row.Enabled ? foreground : muted, + new Rectangle(Scale(32), row.Top, Width - Scale(44), row.Height), format); + } + } + // Match Desktop's 18-DIP contour, including smooth hover edges on + // Windows 10 where DWM does not provide rounded popup corners. + var data = bitmap.LockBits(new Rectangle(Point.Empty, bitmap.Size), ImageLockMode.ReadWrite, PixelFormat.Format32bppPArgb); + try + { + var pixels = new int[Width * Height]; + Marshal.Copy(data.Scan0, pixels, 0, pixels.Length); + var radius = Scale(18); + for (var y = 0; y < Height; y++) + for (var x = 0; x < Width; x++) + { + var dx = Math.Max(Math.Max(radius - (x + 0.5), x + 0.5 - (Width - radius)), 0); + var dy = Math.Max(Math.Max(radius - (y + 0.5), y + 0.5 - (Height - radius)), 0); + var alpha = (uint)(Math.Clamp(radius + 0.5 - Math.Sqrt(dx * dx + dy * dy), 0, 1) * 255 + 0.5); + var pixel = (uint)pixels[y * Width + x]; + pixels[y * Width + x] = (int)((alpha << 24) | (((pixel >> 16 & 255) * alpha / 255) << 16) + | (((pixel >> 8 & 255) * alpha / 255) << 8) | ((pixel & 255) * alpha / 255)); + } + Marshal.Copy(pixels, 0, data.Scan0, pixels.Length); + } + finally { bitmap.UnlockBits(data); } + var screen = GetDC(0); + var dc = CreateCompatibleDC(screen); + var native = bitmap.GetHbitmap(Color.FromArgb(0)); + var previous = SelectObject(dc, native); + try + { + var location = Location; var size = Size; var origin = Point.Empty; + var blend = new Blend { Alpha = 255, Format = 1 }; + if (!UpdateLayeredWindow(Handle, screen, ref location, ref size, dc, ref origin, 0, ref blend, 2)) + throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error()); + } + finally { SelectObject(dc, previous); DeleteObject(native); DeleteDC(dc); ReleaseDC(0, screen); } + } + finally { painting = false; } + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (disposing) menuFont?.Dispose(); + } + + // The layered surface paints every row. Native child controls retain their + // keyboard, invoke and toggle accessibility patterns without opaque corners. + private sealed class MenuLabel : Label + { + protected override void OnPaint(PaintEventArgs e) { } + protected override void OnPaintBackground(PaintEventArgs e) { } + } + private sealed class MenuButton : Button + { + public MenuButton() => SetStyle(ControlStyles.UserPaint, true); + protected override void OnPaint(PaintEventArgs e) { } + protected override void OnPaintBackground(PaintEventArgs e) { } + } + private sealed class MenuCheckBox : CheckBox + { + public MenuCheckBox() { Appearance = Appearance.Button; SetStyle(ControlStyles.UserPaint, true); } + protected override void OnPaint(PaintEventArgs e) { } + protected override void OnPaintBackground(PaintEventArgs e) { } + } + + [StructLayout(LayoutKind.Sequential)] private struct Blend { public byte Operation, Flags, Alpha, Format; } + [DllImport("user32.dll")] private static extern uint GetDpiForWindow(nint window); + [DllImport("user32.dll")] private static extern nint GetDC(nint window); + [DllImport("user32.dll")] private static extern int ReleaseDC(nint window, nint dc); + [DllImport("gdi32.dll")] private static extern nint CreateCompatibleDC(nint dc); + [DllImport("gdi32.dll")] private static extern bool DeleteDC(nint dc); + [DllImport("gdi32.dll")] private static extern nint SelectObject(nint dc, nint value); + [DllImport("gdi32.dll")] private static extern bool DeleteObject(nint value); + [DllImport("user32.dll", SetLastError = true)] private static extern bool UpdateLayeredWindow(nint window, nint screen, + ref Point location, ref Size size, nint dc, ref Point origin, uint color, ref Blend blend, uint flags); +} diff --git a/src/Focalet.Flutter/pubspec.yaml b/src/Focalet.Flutter/pubspec.yaml index 3615b65c..3c7e0f7e 100644 --- a/src/Focalet.Flutter/pubspec.yaml +++ b/src/Focalet.Flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: focalet_flutter description: Focalet's cross-platform floating UI. repository: https://github.com/timctho/focalet publish_to: 'none' -version: 0.3.0+14 +version: 0.3.1+15 environment: sdk: ^3.13.2 diff --git a/tests/Focalet.Windows.Tests/CaptureHotkeyAcceptance.cs b/tests/Focalet.Windows.Tests/CaptureHotkeyAcceptance.cs index e0526e70..18a89ce1 100644 --- a/tests/Focalet.Windows.Tests/CaptureHotkeyAcceptance.cs +++ b/tests/Focalet.Windows.Tests/CaptureHotkeyAcceptance.cs @@ -47,6 +47,13 @@ public static async Task RunAsync(CaptureClipboardBatch batch) SendHotkey(capture: false); await ForegroundRoutingAcceptance.WaitFor(() => context.Busy, "Paste did not start."); SendHotkey(capture: false); // A press while busy must not queue another batch. + await ForegroundRoutingAcceptance.WaitFor(() => Count(rich.Rtf!, @"\pict") >= 1, "The first image was not dispatched."); + // Real shortcut presses hold Alt across multiple paste-loop ticks. + // Ignore the busy invocation and wait for release without duplicating + // or abandoning the already accepted image. + SendHotkey(capture: false, releaseAlt: false); + try { await Task.Delay(250); } + finally { SendInput(1, [Key(0x12, true)], Marshal.SizeOf()); } await ForegroundRoutingAcceptance.WaitFor(() => Count(rich.Rtf!, @"\pict") == 2 && !context.Busy, "Alt+A did not paste the pending batch.", 20000); if (notices.Count != 0) throw new InvalidOperationException("Successful paste displayed a notification."); // RichEdit represents each pasted image as a space in Text and strips @@ -116,13 +123,13 @@ public static async Task RunAsync(CaptureClipboardBatch batch) private static int Count(string value, string part) => value.Split(part, StringSplitOptions.None).Length - 1; - private static void SendHotkey(bool capture) + private static void SendHotkey(bool capture, bool releaseAlt = true) { var keys = new List { Key(0x12) }; if (capture) keys.Add(Key(0x10)); keys.Add(Key(0x41)); keys.Add(Key(0x41, true)); if (capture) keys.Add(Key(0x10, true)); - keys.Add(Key(0x12, true)); + if (releaseAlt) keys.Add(Key(0x12, true)); if (SendInput((uint)keys.Count, keys.ToArray(), Marshal.SizeOf()) != keys.Count) throw new InvalidOperationException("Could not dispatch the fixture hotkey."); } diff --git a/tests/Focalet.Windows.Tests/CaptureTrayMenuAcceptance.cs b/tests/Focalet.Windows.Tests/CaptureTrayMenuAcceptance.cs new file mode 100644 index 00000000..ce8f7471 --- /dev/null +++ b/tests/Focalet.Windows.Tests/CaptureTrayMenuAcceptance.cs @@ -0,0 +1,110 @@ +using Focalet.CaptureTool; +using Focalet.Windows; +using FlaUI.UIA3; +using System.Drawing; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +internal static class CaptureTrayMenuAcceptance +{ + public static int Run() + { + Exception? failure = null; + var previous = CapturePasteTarget.RememberWindow(); + var thread = new Thread(() => + { + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); + using var loop = new Form { ShowInTaskbar = false, Location = new(-30000, -30000), StartPosition = FormStartPosition.Manual }; + loop.Shown += async (_, _) => + { + loop.Hide(); + try { await RunAsync(); } + catch (Exception error) { failure = error; } + finally { loop.Close(); } + }; + Application.Run(loop); + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); thread.Join(); + previous?.Restore(); + if (failure is null) return 0; + Console.Error.WriteLine(failure); return 1; + } + + public static async Task RunAsync() + { + var invoked = 0; + var textOnly = false; + var backdrop = Color.FromArgb(16, 88, 128); + using var background = new Form { Text = "Synthetic Capture menu fixture", Size = new(900, 700), + BackColor = backdrop, StartPosition = FormStartPosition.CenterScreen, TopMost = true }; + using var menu = new CaptureTrayMenu(new("No capture ready"), new("Capture · Shift+Alt+A", () => invoked++), + new("Copy last batch", () => invoked++), new("Copy text", () => invoked++), + new("Text only", () => textOnly = !textOnly, () => textOnly), + new("Slower image paste", () => { }, () => false), new("Quit", () => invoked++)); + background.Show(); background.Activate(); + var point = new Point(background.Right - 60, background.Bottom - 60); + menu.ShowAt(point, "2 regions ready · Alt+A to paste"); + await ForegroundRoutingAcceptance.WaitFor(() => menu.Controls[1].Focused, "Menu did not focus its first action."); + await Task.Delay(150); + var png = ScreenCapture.CapturePng(menu.Bounds); + Directory.CreateDirectory("artifacts"); + File.WriteAllBytes("artifacts/capture-tray-menu.png", png); + using (var stream = new MemoryStream(png)) + using (var bitmap = new Bitmap(stream)) + { + bool Near(Color a, Color b) => Math.Abs(a.R - b.R) + Math.Abs(a.G - b.G) + Math.Abs(a.B - b.B) < 8; + foreach (var corner in new[] { new Point(0, 0), new Point(bitmap.Width - 1, 0), + new Point(0, bitmap.Height - 1), new Point(bitmap.Width - 1, bitmap.Height - 1) }) + if (!Near(bitmap.GetPixel(corner.X, corner.Y), backdrop)) + throw new InvalidOperationException("Tray menu has opaque square corners."); + if (!Near(bitmap.GetPixel(bitmap.Width / 2, 2), Color.FromArgb(40, 40, 40))) + throw new InvalidOperationException("Tray menu did not render Desktop's surface color."); + var radius = (int)Math.Round(18 * menu.DeviceDpi / 96.0); + var antialiased = false; + for (var y = 0; y < radius; y++) + for (var x = 0; x < radius; x++) + { + var pixel = bitmap.GetPixel(x, y); + if (!Near(pixel, backdrop) && !Near(pixel, Color.FromArgb(40, 40, 40))) antialiased = true; + } + if (!antialiased) throw new InvalidOperationException("Tray menu rounded edge is not antialiased."); + } + Console.WriteLine("Tray: rendered corners and pixels verified; checking keyboard."); + PostMessage(menu.Controls[1].Handle, 0x0100, 0x28, 0); // Down + await ForegroundRoutingAcceptance.WaitFor(() => menu.Controls[2].Focused, "Menu keyboard navigation did not move to Copy."); + PostMessage(menu.Controls[2].Handle, 0x0100, 0x1B, 0); // Escape + await ForegroundRoutingAcceptance.WaitFor(() => !menu.Visible, "Escape did not dismiss the menu."); + Console.WriteLine("Tray: keyboard dismissal verified; checking accessible toggle."); + menu.ShowAt(point, "2 regions ready · Alt+A to paste"); + var handle = menu.Handle; + // Query our own native controls from MTA while the UI thread pumps; + // UIA providers may synchronously call back into those controls. + await Task.Run(() => + { + using var automation = new UIA3Automation { ConnectionTimeout = TimeSpan.FromSeconds(1), TransactionTimeout = TimeSpan.FromSeconds(1) }; + var toggle = automation.FromHandle(handle).FindFirstDescendant(automation.ConditionFactory.ByName("Text only")); + if (toggle is null || !toggle.Patterns.Toggle.IsSupported) throw new InvalidOperationException("Text-only preference lost native toggle accessibility."); + toggle.Patterns.Toggle.Pattern.Toggle(); + }).WaitAsync(TimeSpan.FromSeconds(5)); + await ForegroundRoutingAcceptance.WaitFor(() => textOnly && !menu.Visible, "Accessible toggle did not update the preference and close."); + Console.WriteLine("Tray: accessible toggle verified; checking reopen and invoke."); + menu.ShowAt(point, "2 regions ready · Alt+A to paste"); + if (((CheckBox)menu.Controls[4]).CheckState != CheckState.Checked) throw new InvalidOperationException("Menu lost its checked preference when reopened."); + await Task.Delay(100); + File.WriteAllBytes("artifacts/capture-tray-menu-checked.png", ScreenCapture.CapturePng(menu.Bounds)); + await Task.Run(() => + { + using var automation = new UIA3Automation { ConnectionTimeout = TimeSpan.FromSeconds(1), TransactionTimeout = TimeSpan.FromSeconds(1) }; + var copy = automation.FromHandle(handle).FindFirstDescendant(automation.ConditionFactory.ByName("Copy text"))!; + copy.Patterns.Invoke.Pattern.Invoke(); + }).WaitAsync(TimeSpan.FromSeconds(5)); + await ForegroundRoutingAcceptance.WaitFor(() => invoked == 1 && !menu.Visible, "Accessible menu action did not invoke exactly once."); + menu.ShowAt(point, "2 regions ready · Alt+A to paste"); + background.Activate(); + await ForegroundRoutingAcceptance.WaitFor(() => !menu.Visible, "Menu remained open after focus moved away."); + Console.WriteLine("PASS Capture tray uses smooth 18-DIP corners, native actions/toggles, keyboard navigation, persistent checks and focus dismissal."); + } + + [DllImport("user32.dll")] private static extern bool PostMessage(nint window, uint message, nint key, nint value); +} diff --git a/tests/Focalet.Windows.Tests/ClipboardAcceptance.cs b/tests/Focalet.Windows.Tests/ClipboardAcceptance.cs index 737e4a1c..cbb2dbf9 100644 --- a/tests/Focalet.Windows.Tests/ClipboardAcceptance.cs +++ b/tests/Focalet.Windows.Tests/ClipboardAcceptance.cs @@ -93,6 +93,7 @@ private static void Exercise(bool focusOnly) await ChromiumClipboardAcceptance.RunAsync(batch); await ElectronClipboardAcceptance.RunAsync(batch); await CaptureHotkeyAcceptance.RunAsync(batch); + await CaptureTrayMenuAcceptance.RunAsync(); if (!richTarget.Restore()) throw new InvalidOperationException("Rich editor was not restored after browser acceptance."); using var interrupt = new System.Windows.Forms.Timer { Interval = 150 }; interrupt.Tick += (_, _) => { interrupt.Stop(); text.Focus(); }; diff --git a/tests/Focalet.Windows.Tests/ElectronClipboardAcceptance.cs b/tests/Focalet.Windows.Tests/ElectronClipboardAcceptance.cs index 50671a2b..ad13b664 100644 --- a/tests/Focalet.Windows.Tests/ElectronClipboardAcceptance.cs +++ b/tests/Focalet.Windows.Tests/ElectronClipboardAcceptance.cs @@ -12,9 +12,10 @@ public static async Task RunAsync(CaptureClipboardBatch batch) var fixture = Path.GetFullPath("tests/clipboard-electron"); var electron = Path.Combine(fixture, "node_modules/electron/dist/electron.exe"); if (!File.Exists(electron)) throw new InvalidOperationException("Install the locked tests/clipboard-electron dependencies first."); - foreach (var mode in new[] { "terminal", "native-chat" }) + foreach (var mode in new[] { "terminal", "native-chat", "native-chat-transient-focus", "native-chat-other-input" }) foreach (var slowerImages in new[] { false, true }) { + if (slowerImages && mode is "native-chat-transient-focus" or "native-chat-other-input") continue; var pace = slowerImages ? "slower" : "fast"; var temporary = Path.Combine(Path.GetTempPath(), "focalet-electron-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(temporary); @@ -37,9 +38,23 @@ public static async Task RunAsync(CaptureClipboardBatch batch) input.Focus(); await ForegroundRoutingAcceptance.WaitFor(() => input.Properties.HasKeyboardFocus.ValueOrDefault, "Electron input did not acquire focus."); target = CapturePasteTarget.RememberWindow() ?? throw new InvalidOperationException("Electron input was not current."); + var observation = await Task.Run(() => CapturePasteTarget.ObserveInput(automation, target)); + if (observation.Kind != CaptureInputKind.Input) throw new InvalidOperationException("Electron editor was not recognized as the chosen input."); + target = observation.Target; var elapsed = Stopwatch.StartNew(); var result = await CapturePasteSequence.PasteAsync(batch, target, false, slowerImages); elapsed.Stop(); + if (mode == "native-chat-other-input") + { + if (result.StoppedBecause is null || result.StepsSent != 1) + throw new InvalidOperationException("Paste continued into a different editor: " + result); + await Task.Delay(500); + using var stopped = JsonDocument.Parse(File.ReadAllText(Path.Combine(temporary, "result.json"))); + if (stopped.RootElement.GetProperty("events").GetArrayLength() != 1) + throw new InvalidOperationException("Changing editors dispatched more than the first image."); + Console.WriteLine("PASS Electron paste stops after the accepted image when a different editor gains focus."); + continue; + } if (result.StoppedBecause is not null) throw new InvalidOperationException("Electron sequence stopped: " + result); await Task.Delay(1800); var json = File.ReadAllText(Path.Combine(temporary, "result.json")); diff --git a/tests/Focalet.Windows.Tests/Program.cs b/tests/Focalet.Windows.Tests/Program.cs index 3a5428b5..8c46ba21 100644 --- a/tests/Focalet.Windows.Tests/Program.cs +++ b/tests/Focalet.Windows.Tests/Program.cs @@ -6,6 +6,7 @@ if (args.Length == 2 && args[0] == "--console-receiver") return ConsoleRoutingAcceptance.Receive(args[1]); if (args.Contains("--paste-acceptance", StringComparer.Ordinal)) return ClipboardAcceptance.Run(); +if (args.Contains("--tray-menu-acceptance", StringComparer.Ordinal)) return CaptureTrayMenuAcceptance.Run(); if (args.Contains("--browser-focus-acceptance", StringComparer.Ordinal)) return ClipboardAcceptance.Run(focusOnly: true); var tests = new (string Name, Action Run)[] diff --git a/tests/clipboard-electron/receiver.html b/tests/clipboard-electron/receiver.html index 421b7e65..8e7a6e2e 100644 --- a/tests/clipboard-electron/receiver.html +++ b/tests/clipboard-electron/receiver.html @@ -2,14 +2,23 @@

Native Electron image and context

+