diff --git a/SnapActions.Tests/MouseHookGeometryTests.cs b/SnapActions.Tests/MouseHookGeometryTests.cs
index 161cc63..f6a5bbb 100644
--- a/SnapActions.Tests/MouseHookGeometryTests.cs
+++ b/SnapActions.Tests/MouseHookGeometryTests.cs
@@ -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);
+ }
}
diff --git a/SnapActions/Core/MouseHook.cs b/SnapActions/Core/MouseHook.cs
index d1e5535..0cccb2e 100644
--- a/SnapActions/Core/MouseHook.cs
+++ b/SnapActions/Core/MouseHook.cs
@@ -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)
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// Visible to tests so they can sanity-check the value is reasonable (typically 16 = 4²)
+ /// without taking a hard dependency on a specific number.
+ ///
+ internal static readonly int LongPressMoveCancelDistSq = ComputeSquaredThreshold(SM_CXDRAG, SM_CYDRAG, fallback: 4);
+
+ ///
+ /// 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.
+ ///
+ 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;
+ ///
+ /// 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.
+ ///
+ 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;
@@ -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; }
diff --git a/SnapActions/Core/SelectionTracker.cs b/SnapActions/Core/SelectionTracker.cs
index b59a115..ea0e37a 100644
--- a/SnapActions/Core/SelectionTracker.cs
+++ b/SnapActions/Core/SelectionTracker.cs
@@ -83,12 +83,17 @@ private void OnMouseDown(MouseHook.POINT pt)
/// - TextCapture.WM_COPY then unconditional Ctrl+Insert fallback if WM_COPY returned
/// empty. Empty captured text aborts here.
///
- /// 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.
///
diff --git a/SnapActions/Core/TextCapture.cs b/SnapActions/Core/TextCapture.cs
index e8c2275..fe71531 100644
--- a/SnapActions/Core/TextCapture.cs
+++ b/SnapActions/Core/TextCapture.cs
@@ -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);
@@ -167,6 +188,124 @@ private static void RestoreClipboard(Dictionary? snapshot)
///
private const int TextPatternParentWalkDepth = 6;
+ internal enum SelectionProbeOutcome
+ {
+ /// UIA gave us the selected text directly — use it and skip the clipboard pipeline.
+ HasText,
+ /// UIA definitively said no selection (empty TextPattern, or a non-text item). Suppress.
+ Suppress,
+ /// UIA couldn't determine. Fall through to WM_COPY / Ctrl+Insert.
+ Unknown,
+ }
+
+ internal readonly record struct SelectionProbe(SelectionProbeOutcome Outcome, string? Text, string? Reason);
+
+ ///
+ /// 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.
+ ///
+ private static readonly System.Windows.Automation.ControlType[] NonTextItemTypes =
+ [
+ System.Windows.Automation.ControlType.DataItem,
+ System.Windows.Automation.ControlType.ListItem,
+ System.Windows.Automation.ControlType.TreeItem,
+ ];
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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.
+ ///
+ internal static async Task 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}");
+ }
+ });
+ }
+
///
/// 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
diff --git a/SnapActions/SnapActions.csproj b/SnapActions/SnapActions.csproj
index 38ab19a..4aac3d1 100644
--- a/SnapActions/SnapActions.csproj
+++ b/SnapActions/SnapActions.csproj
@@ -11,7 +11,7 @@
app.manifest
SnapActions
SnapActions
- 1.6.15
+ 1.6.16
$(NoWarn);WFO0003