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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/capture-tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 35 additions & 8 deletions src/Focalet.CaptureTool/CapturePasteSequence.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,14 @@ public static async Task<CapturePasteResult> 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++;

Expand All @@ -41,11 +40,21 @@ public static async Task<CapturePasteResult> 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;
}
Expand All @@ -60,6 +69,24 @@ public static async Task<CapturePasteResult> PasteAsync(CaptureClipboardBatch ba
return new(sent, unreadImages);
}

private static async Task<string?> 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
Expand Down
36 changes: 31 additions & 5 deletions src/Focalet.CaptureTool/CapturePasteTarget.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
namespace Focalet.CaptureTool;

internal enum CaptureInputKind { Unknown, Input, Protected }
internal enum CaptureInputState { Current, Settling, Changed }
internal sealed record CaptureInputObservation(CapturePasteTarget Target, CaptureInputKind Kind);

/// <summary>Identity of the input focused when the user invokes paste.</summary>
internal sealed record CapturePasteTarget(nint Window, nint Focus, uint ProcessId, long ProcessStarted)
: 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;
Expand Down Expand Up @@ -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) =>
Expand All @@ -71,13 +73,37 @@ private static string WindowClass(nint window)
internal bool ContainsWindow(nint window) => window == Window || GetAncestor(window, 2) == Window;

public async Task<bool> IsInputCurrentAsync()
=> await InputStateAsync() == CaptureInputState.Current;

internal async Task<CaptureInputState> 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; }
});
}

Expand Down
42 changes: 21 additions & 21 deletions src/Focalet.CaptureTool/CapturePasteTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RegionCaptureSession.SelectedBatch> select;
private readonly Action<string, string>? 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;

Expand All @@ -69,23 +68,23 @@ public CaptureContext(Func<RegionCaptureSession.SelectedBatch>? 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.");
}
Expand All @@ -101,6 +100,7 @@ private async void Capture()
{
if (busy) return;
busy = true;
menu.Hide();
try
{
if (!await WaitForKeys()) return;
Expand Down Expand Up @@ -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.");
}
Expand All @@ -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."); }
}

Expand Down
Loading
Loading