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
24 changes: 24 additions & 0 deletions SnapActions.Tests/MouseHookGeometryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,28 @@ public void Position_Center_NotScrollbar()
{
Assert.False(MouseHook.LooksLikeScrollbarPosition(P(500, 400), Rect, isRtl: false));
}

// ── System-metric-derived thresholds ─────────────────────────────────────
//
// We can't pin a specific number — SM_CXDRAG and SM_CXDOUBLECLK depend on the OS / user
// settings — but we can check the resolved value lands in a sensible window. If the
// GetSystemMetrics P/Invoke ever returns 0 (headless runner / very stripped image), the
// ComputeSquaredThreshold fallback to 4 px keeps the value in this range.

[Fact]
public void LongPressMoveCancelDistSq_InSensibleRange()
{
// Typical Windows defaults give 4 px → 16. Allow up to 32 px (huge custom drag rect,
// e.g. touch-optimized) but reject anything that would let an 8 px drag slip past us
// (the bug we're fixing).
Assert.InRange(MouseHook.LongPressMoveCancelDistSq, 9, 32 * 32);
}

[Fact]
public void MultiClickRadiusSq_InSensibleRange()
{
// Typical Windows defaults give 4 px → 16. Same upper bound as drag, and a floor of
// 9 (3 px) so an absurdly tight metric doesn't break legitimate double-clicks.
Assert.InRange(MouseHook.MultiClickRadiusSq, 9, 32 * 32);
}
}
54 changes: 48 additions & 6 deletions SnapActions/Core/MouseHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,55 @@ public class MouseHook : IDisposable
private const int GWL_EXSTYLE = -20;
private const long WS_EX_LAYOUTRTL = 0x00400000;

// Tuning constants (squared distances in pixels, time in ms).
// 8px² = 64 — radius below which we still consider the cursor "stationary" during a hold.
private const int LongPressMoveCancelDistSq = 64;
// 10px² = 100 — minimum drag distance to count as a selection.
// GetSystemMetrics indices for the OS-defined click vs. drag thresholds. Same values
// Windows uses internally for DragDetect() — keeping our thresholds in lockstep means we
// distinguish "click" from "drag" the way every other Windows app does, and we respect
// user / OS / DPI overrides automatically.
private const int SM_CXDRAG = 68; // half-width of the drag rectangle (typ. 4 px)
private const int SM_CYDRAG = 69; // half-height of the drag rectangle (typ. 4 px)
private const int SM_CXDOUBLECLK = 36; // half-width of the double-click rectangle (typ. 4 px)
private const int SM_CYDOUBLECLK = 37; // half-height of the double-click rectangle (typ. 4 px)

/// <summary>
/// Squared system drag threshold. A motion of more than √value pixels from the mouse-down
/// point cancels the long-press timer. Read once at process start because system metrics
/// don't change without a user-session restart.
/// </summary>
/// <remarks>
/// Visible to tests so they can sanity-check the value is reasonable (typically 16 = 4²)
/// without taking a hard dependency on a specific number.
/// </remarks>
internal static readonly int LongPressMoveCancelDistSq = ComputeSquaredThreshold(SM_CXDRAG, SM_CYDRAG, fallback: 4);

/// <summary>
/// Squared system double-click radius. Two clicks within √value pixels are treated as a
/// multi-click cluster. Tighter than our old hardcoded 64 (8 px) so a slow drag onset
/// between two clicks doesn't get misread as a double-click in the same spot.
/// </summary>
internal static readonly int MultiClickRadiusSq = ComputeSquaredThreshold(SM_CXDOUBLECLK, SM_CYDOUBLECLK, fallback: 4);

// 10px² = 100 — minimum drag distance to count as a selection. Not a system metric — this
// is our own "the user definitely intended to drag-select" floor, deliberately above the
// drag-cancel threshold so a small click-then-twitch doesn't fire SelectionLikely.
private const int MinDragSelectDistSq = 100;
// 8px² = 64 — clicks within this radius of the previous one form a multi-click cluster.
private const int MultiClickRadiusSq = 64;
private const int MinClickDurationMs = 80;
private const int MultiClickWindowMs = 500;

/// <summary>
/// max(cx, cy)² with a sane fallback when GetSystemMetrics returns 0 (e.g. headless / RDP
/// during init). We use max rather than an ellipse for two reasons: cardinal-direction
/// motion (just-x or just-y) gets the full allowance, and a single comparison against
/// distSq keeps the hot path branch-free.
/// </summary>
private static int ComputeSquaredThreshold(int cxIndex, int cyIndex, int fallback)
{
int cx = GetSystemMetrics(cxIndex);
int cy = GetSystemMetrics(cyIndex);
int max = Math.Max(cx, cy);
if (max <= 0) max = fallback;
return max * max;
}

private delegate IntPtr LowLevelMouseProc(int nCode, IntPtr wParam, IntPtr lParam);

private readonly LowLevelMouseProc _hookProc;
Expand Down Expand Up @@ -443,6 +482,9 @@ private static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, IntPtr wP
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
private static extern IntPtr GetWindowLongPtr(IntPtr hWnd, int nIndex);

[DllImport("user32.dll")]
private static extern int GetSystemMetrics(int nIndex);

// Internal so SnapActions.Tests can construct synthetic RECTs for scrollbar-helper tests.
[StructLayout(LayoutKind.Sequential)]
internal struct RECT { public int left, top, right, bottom; }
Expand Down
11 changes: 8 additions & 3 deletions SnapActions/Core/SelectionTracker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,17 @@ private void OnMouseDown(MouseHook.POINT pt)
/// <item>TextCapture.WM_COPY then unconditional Ctrl+Insert fallback if WM_COPY returned
/// empty. Empty captured text aborts here.</item>
/// </list>
/// Three UIA-based gates have been added and removed across v1.6.5–1.6.12:
/// Three UIA-based gates were added and removed across v1.6.5–1.6.12:
/// • atPointTask (mouse-up UIA) — removed v1.6.10, false-positive on whitespace endings
/// • IsForegroundTextCapable (focused-element UIA) — removed v1.6.10, browsers focus parent panes
/// • atDownTask (mouse-down UIA) — removed v1.6.12, blocks selections in apps with shallow UIA trees
/// The lesson: UIA's TextPattern coverage is too inconsistent across apps to be a reliable gate.
/// Drag-and-drop / object-drag false-positives now fall back to the user's ExcludedApps list.
/// The lesson from those: UIA's TextPattern coverage is too inconsistent across apps to be
/// a *required* gate (false negatives broke legitimate selections).
/// TextCapture.ProbeSelectionViaUIA (the new gate inside the pipeline below) avoids that
/// trap because it only acts on *definitive* answers: it suppresses when UIA confirms an
/// empty selection or a non-text item type, and falls through to WM_COPY otherwise — so
/// the historical false-negative apps (Java Swing, some Edge, custom Electron) still work
/// via the clipboard path.
/// LongPress still uses IsTextInputAtPoint at the cursor — paste mode showing on a button or
/// scrollbar is worse than the same false-positive cost there.
/// </summary>
Expand Down
139 changes: 139 additions & 0 deletions SnapActions/Core/TextCapture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,27 @@ public static class TextCapture
}
try
{
// UIA pre-gate. Three outcomes:
// HasText — there is a real text selection; use it, skip the whole clipboard dance.
// Suppress — UIA *definitively* says no selection (TextPattern present but degenerate,
// or focus is on a non-text item like an Explorer file). Bail out.
// Unknown — UIA can't tell (no TextPattern, exception, shallow tree). Fall through.
// Why the pipeline used to fire SelectionLikely on a double-click in Explorer or a
// double-click on a desktop icon: WM_COPY succeeds against those (copies the filename
// or item text) even though no *text* is selected. The Suppress branch kills that path
// for any app that exposes either TextPattern or item-selection patterns.
var probe = await ProbeSelectionViaUIA();
switch (probe.Outcome)
{
case SelectionProbeOutcome.HasText:
SnapActions.Helpers.Log.Info($"UIA pre-gate returned text ({probe.Text!.Length} chars) — skipping clipboard pipeline");
return probe.Text;
case SelectionProbeOutcome.Suppress:
SnapActions.Helpers.Log.Info($"UIA pre-gate suppressed capture: {probe.Reason}");
return null;
// case SelectionProbeOutcome.Unknown: fall through
}

// Snapshot ALL clipboard formats so images/files/RTF survive
var saved = await Application.Current.Dispatcher.InvokeAsync(SnapshotClipboard);

Expand Down Expand Up @@ -167,6 +188,124 @@ private static void RestoreClipboard(Dictionary<string, object>? snapshot)
/// </summary>
private const int TextPatternParentWalkDepth = 6;

internal enum SelectionProbeOutcome
{
/// <summary>UIA gave us the selected text directly — use it and skip the clipboard pipeline.</summary>
HasText,
/// <summary>UIA definitively said no selection (empty TextPattern, or a non-text item). Suppress.</summary>
Suppress,
/// <summary>UIA couldn't determine. Fall through to WM_COPY / Ctrl+Insert.</summary>
Unknown,
}

internal readonly record struct SelectionProbe(SelectionProbeOutcome Outcome, string? Text, string? Reason);

/// <summary>
/// Item-style control types that are NOT text. When the focused element is one of these
/// AND exposes SelectionItemPattern AND we found no TextPattern up the tree, we treat the
/// "selection" as an item selection (file in Explorer, desktop icon, list-box row, tree
/// node) and suppress. Deliberately narrow — Pane / Custom / Document stay out because
/// browsers and Electron focus those for real text contexts.
/// </summary>
private static readonly System.Windows.Automation.ControlType[] NonTextItemTypes =
[
System.Windows.Automation.ControlType.DataItem,
System.Windows.Automation.ControlType.ListItem,
System.Windows.Automation.ControlType.TreeItem,
];

/// <summary>
/// Probes UI Automation to decide whether a real text selection exists right now. Layered
/// gate to prevent the WM_COPY pipeline from misreading non-text contexts (Explorer file
/// double-click, desktop icon, list row) as text selections. Runs on a worker thread —
/// UIA calls can take 50–500 ms cold.
/// </summary>
/// <remarks>
/// Three UIA-based gates were tried and removed across v1.6.5–1.6.12 because they over-
/// suppressed legitimate selections. This one is more conservative: it only suppresses
/// when UIA gives a *definitive* answer — TextPattern explicitly empty, or a clearly non-
/// text item element. Anything ambiguous (no TextPattern, exception, shallow tree)
/// returns Unknown, which leaves the existing WM_COPY → Ctrl+Insert fallback intact.
/// </remarks>
internal static async Task<SelectionProbe> ProbeSelectionViaUIA()
{
return await Task.Run(() =>
{
AutomationElement? originalFocused = null;
try
{
originalFocused = AutomationElement.FocusedElement;
if (originalFocused == null)
return new SelectionProbe(SelectionProbeOutcome.Unknown, null, "no focused element");

// Walk up looking for TextPattern. If ANY ancestor has TextPattern with non-empty
// selection → HasText (return immediately). If we exhaust the walk and saw at least
// one TextPattern but all were empty → Suppress. If we never saw TextPattern → fall
// through to the item-element check below.
var walker = TreeWalker.RawViewWalker;
var element = originalFocused;
bool sawAnyTextPattern = false;
for (int depth = 0; element != null && depth < TextPatternParentWalkDepth; depth++)
{
try
{
if (element.TryGetCurrentPattern(TextPattern.Pattern, out var pat))
{
sawAnyTextPattern = true;
var tp = (TextPattern)pat;
var ranges = tp.GetSelection();
if (ranges != null && ranges.Length > 0)
{
var combined = ranges.Length == 1
? ranges[0].GetText(-1)
: string.Join("\n",
ranges.Select(r => r.GetText(-1)).Where(s => !string.IsNullOrEmpty(s)));
if (!string.IsNullOrEmpty(combined))
return new SelectionProbe(SelectionProbeOutcome.HasText, combined, null);
}
// TextPattern at this level returned no selection text. Keep walking up
// — an ancestor pane / document may have the real selection (browsers
// often expose TextPattern at multiple levels with the leaf empty).
}
}
catch { /* per-level UIA failure — try the parent */ }

try { element = walker.GetParent(element); }
catch { break; }
}

if (sawAnyTextPattern)
return new SelectionProbe(SelectionProbeOutcome.Suppress,
null, "TextPattern present but selection is empty");

// Layer C: no TextPattern anywhere up the walk. Check the originally-focused
// element for non-text item patterns — Explorer file rows, desktop icons,
// list-box rows. SelectionItemPattern means "I am a selectable item" (vs.
// text); ControlType keeps us off Pane / Custom / Document which browsers
// and Electron focus for real text contexts.
try
{
var ct = originalFocused.Current.ControlType;
bool isItemType = NonTextItemTypes.Contains(ct);
bool hasItemPattern = originalFocused.TryGetCurrentPattern(
SelectionItemPattern.Pattern, out _);
if (isItemType && hasItemPattern)
return new SelectionProbe(SelectionProbeOutcome.Suppress,
null, $"focused element is {ct.ProgrammaticName} with SelectionItemPattern");
}
catch { /* couldn't read ControlType — fall through to Unknown */ }

return new SelectionProbe(SelectionProbeOutcome.Unknown, null, "no TextPattern, not a known non-text item");
}
catch (Exception ex)
{
// Total UIA failure — be permissive (fall through to clipboard pipeline) so we
// don't silently break selections in apps where UIA misbehaves.
return new SelectionProbe(SelectionProbeOutcome.Unknown, null, $"UIA exception: {ex.GetType().Name}");
}
});
}

/// <summary>
/// Reads the current selection via UI Automation. Returns null when no focused element,
/// no TextPattern within the walk depth, no selection ranges, or any UIA failure. Runs on
Expand Down
2 changes: 1 addition & 1 deletion SnapActions/SnapActions.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<ApplicationManifest>app.manifest</ApplicationManifest>
<AssemblyName>SnapActions</AssemblyName>
<RootNamespace>SnapActions</RootNamespace>
<Version>1.6.15</Version>
<Version>1.6.16</Version>
<!-- We're WPF-primary (WinForms is only NotifyIcon). The manifest's PerMonitorV2 declaration
is what the OS reads at process startup. WinForms' WFO0003 wants its own DPI hook. -->
<NoWarn>$(NoWarn);WFO0003</NoWarn>
Expand Down
Loading