From 86c3623d3f0745d0e076a60efefd9889d0f3e354 Mon Sep 17 00:00:00 2001 From: Rocky Shi Date: Fri, 2 Oct 2026 20:27:23 +1300 Subject: [PATCH] Allow activation key to reveal existing text selection --- README.md | 4 +- .../Resources/Localizable.xcstrings | 8 +- .../SelectionBarSettingsView.swift | 2 +- .../Core/SelectionMonitor.swift | 121 +++++++++-- .../SelectionBarActivationModifier.swift | 2 +- .../Settings/SelectionBarSettingsStore.swift | 2 +- .../SelectionMonitorTests.swift | 196 +++++++++++++++++- 7 files changed, 303 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 7594ac8..314f4aa 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,9 @@ open SelectionBar.app ### Do Not Disturb Mode -Hold a configured modifier key while selecting text to activate the toolbar. When enabled, the toolbar will not appear unless the modifier key is held down. +Select text, then hold the configured modifier key to show the toolbar. You can +also hold the key while selecting text. Once shown, the toolbar stays open when +you release the key so you can choose an action. ## Troubleshooting diff --git a/Sources/SelectionBarApp/Resources/Localizable.xcstrings b/Sources/SelectionBarApp/Resources/Localizable.xcstrings index a0d0740..4f2c23f 100644 --- a/Sources/SelectionBarApp/Resources/Localizable.xcstrings +++ b/Sources/SelectionBarApp/Resources/Localizable.xcstrings @@ -287,24 +287,24 @@ } } }, - "When enabled, Selection Bar appears only while the selected modifier key is held.": { + "When enabled, select text, then hold the selected key to show Selection Bar.": { "localizations": { "ja": { "stringUnit": { "state": "translated", - "value": "有効時、選択した修飾キーを押している間のみ表示されます。" + "value": "有効時、テキストを選択してから指定したキーを押し続けると、Selection Bar が表示されます。" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "启用后,仅在按住所选修饰键时才显示选择栏。" + "value": "启用后,先选择文本,再按住所选按键以显示选择栏。" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "啟用後,只有按住所選修飾鍵時才會顯示選取工具列。" + "value": "啟用後,先選取文字,再按住所選按鍵以顯示選取工具列。" } } } diff --git a/Sources/SelectionBarApp/SelectionBarSettingsView.swift b/Sources/SelectionBarApp/SelectionBarSettingsView.swift index 910e69f..9da57fc 100644 --- a/Sources/SelectionBarApp/SelectionBarSettingsView.swift +++ b/Sources/SelectionBarApp/SelectionBarSettingsView.swift @@ -102,7 +102,7 @@ private struct SelectionBarGeneralSettingsTab: View { Label("Activation", systemImage: "moon.zzz") } footer: { Text( - "When enabled, Selection Bar appears only while the selected modifier key is held." + "When enabled, select text, then hold the selected key to show Selection Bar." ) } diff --git a/Sources/SelectionBarCore/Core/SelectionMonitor.swift b/Sources/SelectionBarCore/Core/SelectionMonitor.swift index 5e08d4c..1455eb3 100644 --- a/Sources/SelectionBarCore/Core/SelectionMonitor.swift +++ b/Sources/SelectionBarCore/Core/SelectionMonitor.swift @@ -45,7 +45,7 @@ public final class SelectionMonitor { /// Bundle IDs of apps where the selection bar should not appear public var ignoredBundleIDs: Set = [] - /// Whether text selection should only trigger while modifier key is held. + /// Whether showing the toolbar requires the activation modifier. public var requireActivationModifier: Bool = false /// Required modifier key when `requireActivationModifier` is enabled. @@ -57,6 +57,7 @@ public final class SelectionMonitor { nonisolated(unsafe) private var mouseUpMonitor: Any? nonisolated(unsafe) private var mouseDownMonitor: Any? nonisolated(unsafe) private var keyDownMonitor: Any? + nonisolated(unsafe) private var flagsChangedMonitor: Any? nonisolated(unsafe) private var appSwitchObserver: NSObjectProtocol? private let accessibility: SelectionMonitorAccessibilityProviding private let clipboardFallback: SelectionMonitorClipboardFallbackProviding @@ -64,6 +65,7 @@ public final class SelectionMonitor { private var debounceTask: Task? private var isEnabled = false + private var previousModifierFlags: NSEvent.ModifierFlags = [] /// Track mouse-down location to detect drag vs click private var mouseDownLocation: NSPoint? @@ -117,7 +119,10 @@ public final class SelectionMonitor { // `deinit` can run on any thread when the last release happens off-main, // but `NSEvent.removeMonitor` is main-thread-only — so hop rather than // calling it inline. - let monitors = [mouseUpMonitor, mouseDownMonitor, keyDownMonitor].compactMap { $0 } + let monitors = [mouseUpMonitor, mouseDownMonitor, keyDownMonitor, flagsChangedMonitor] + .compactMap { + $0 + } let observer = appSwitchObserver guard !monitors.isEmpty || observer != nil else { return } @@ -150,6 +155,7 @@ public final class SelectionMonitor { return } isEnabled = true + previousModifierFlags = NSEvent.modifierFlags let hasRequiredPermissions = requestRequiredPermissionsIfNeeded() let trusted = checkAccessibilityPermission(promptIfNeeded: false) @@ -195,6 +201,10 @@ public final class SelectionMonitor { removeMonitors() debounceTask?.cancel() debounceTask = nil + previousModifierFlags = [] + mouseDownLocation = nil + mouseDownClickCount = 1 + clearMouseDownWindowOrigin() logger.info("SelectionMonitor stopped") } @@ -206,10 +216,7 @@ public final class SelectionMonitor { [weak self] event in let location = NSEvent.mouseLocation Task { @MainActor in - self?.mouseDownLocation = location - self?.mouseDownClickCount = max(event.clickCount, 1) - self?.captureMouseDownWindowOrigin() - self?.onDismissRequested?() + self?.handleMouseDown(at: location, clickCount: event.clickCount) } } @@ -231,20 +238,34 @@ public final class SelectionMonitor { // Monitor escape key (dismiss) keyDownMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard !Self.isOwnSyntheticEvent(event) else { return } let isEscape = event.keyCode == 53 // Escape let isSelectAll = Self.isSelectAllShortcut(event) - if isEscape { - Task { @MainActor in + Task { @MainActor in + self?.debounceTask?.cancel() + if isEscape { self?.onDismissRequested?() + } else if isSelectAll { + self?.handleSelectAllShortcut(modifierFlags: event.modifierFlags) } - return } + } - if isSelectAll { - Task { @MainActor in - self?.handleSelectAllShortcut(modifierFlags: event.modifierFlags) - } + // Pressing the activation key can reveal an existing selection. + flagsChangedMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { + [weak self] event in + guard !Self.isOwnSyntheticEvent(event) else { return } + let flags = event.modifierFlags + let location = NSEvent.mouseLocation + Task { @MainActor in + guard let frontApp = NSWorkspace.shared.frontmostApplication else { return } + await self?.handleModifierFlagsChanged( + flags, + at: location, + frontmostBundleID: frontApp.bundleIdentifier, + frontmostPID: frontApp.processIdentifier + ) } } @@ -255,6 +276,7 @@ public final class SelectionMonitor { queue: .main ) { [weak self] _ in Task { @MainActor in + self?.debounceTask?.cancel() self?.onDismissRequested?() } } @@ -273,6 +295,10 @@ public final class SelectionMonitor { NSEvent.removeMonitor(monitor) keyDownMonitor = nil } + if let monitor = flagsChangedMonitor { + NSEvent.removeMonitor(monitor) + flagsChangedMonitor = nil + } if let observer = appSwitchObserver { NSWorkspace.shared.notificationCenter.removeObserver(observer) appSwitchObserver = nil @@ -286,14 +312,27 @@ public final class SelectionMonitor { return sqrt(dx * dx + dy * dy) } - private func handleMouseUp( + func handleMouseDown(at mouseLocation: NSPoint, clickCount: Int) { + debounceTask?.cancel() + mouseDownLocation = mouseLocation + mouseDownClickCount = max(clickCount, 1) + captureMouseDownWindowOrigin() + onDismissRequested?() + } + + func handleMouseUp( at mouseLocation: NSPoint, clickCount: Int, modifierFlags: NSEvent.ModifierFlags ) { + defer { + mouseDownLocation = nil + mouseDownClickCount = 1 + clearMouseDownWindowOrigin() + } guard let frontApp = NSWorkspace.shared.frontmostApplication else { return } - // Skip if VoiceTale is the frontmost app + // Skip if SelectionBar is the frontmost app if frontApp.bundleIdentifier == Bundle.main.bundleIdentifier { return } @@ -327,9 +366,6 @@ public final class SelectionMonitor { let isSelectionGesture = wasDrag || effectiveClickCount >= 2 let isMultiClickGesture = effectiveClickCount >= 2 let didMoveWindow = focusedWindowMovedSinceMouseDown(forPID: frontApp.processIdentifier) - mouseDownLocation = nil - mouseDownClickCount = 1 - clearMouseDownWindowOrigin() // Only react to explicit selection gestures (drag, double-click, triple-click). // This prevents stale selections from re-triggering when windows regain focus @@ -367,6 +403,46 @@ public final class SelectionMonitor { } } + func handleModifierFlagsChanged( + _ flags: NSEvent.ModifierFlags, + at mouseLocation: NSPoint, + frontmostBundleID: String?, + frontmostPID: pid_t + ) async { + let wasPressed = isRequiredActivationModifierPressed(in: previousModifierFlags) + previousModifierFlags = flags + guard isEnabled, requireActivationModifier, + !wasPressed, isRequiredActivationModifierPressed(in: flags), + mouseDownLocation == nil + else { return } + + if let bundleID = frontmostBundleID, + bundleID == Bundle.main.bundleIdentifier || ignoredBundleIDs.contains(bundleID) + { + return + } + guard !accessibility.isCurrentProcessElement(at: mouseLocation), + !accessibility.isFocusedElementOwnedByCurrentProcess() + else { return } + + debounceTask?.cancel() + let task = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(120)) + guard !Task.isCancelled else { return } + await self?.querySelectedText( + at: mouseLocation, + isSelectionGesture: true, + isMultiClickGesture: true, + didMoveWindow: false, + allowFocusedTextContextFallback: true, + frontmostBundleID: frontmostBundleID, + frontmostPID: frontmostPID + ) + } + debounceTask = task + await task.value + } + func shouldIgnoreMultiClickOpenAction( frontmostBundleID: String?, clickCount: Int, @@ -434,9 +510,9 @@ public final class SelectionMonitor { frontmostBundleID: String?, frontmostPID: pid_t ) async { - guard isEnabled else { return } + guard isEnabled, !Task.isCancelled else { return } - guard AXIsProcessTrusted() else { + guard accessibility.checkAccessibilityPermission(promptIfNeeded: false) else { logger.warning("Accessibility not trusted, cannot query selected text") return } @@ -480,6 +556,7 @@ public final class SelectionMonitor { logger.debug("AX query failed, trying clipboard fallback") if let text = await queryViaClipboard(isSelectionGesture: isSelectionGesture) { + guard isEnabled, !Task.isCancelled else { return } logger.info("Clipboard selected text (\(text.count) chars)") onTextSelected?(text, mouseLocation) } @@ -634,6 +711,10 @@ public final class SelectionMonitor { return true } + private static func isOwnSyntheticEvent(_ event: NSEvent) -> Bool { + event.cgEvent?.getIntegerValueField(.eventSourceUnixProcessID) == Int64(getpid()) + } + private func isRequiredActivationModifierPressed(in flags: NSEvent.ModifierFlags) -> Bool { guard requireActivationModifier else { return true } let normalizedFlags = flags.intersection(.deviceIndependentFlagsMask) diff --git a/Sources/SelectionBarCore/Models/SelectionBarActivationModifier.swift b/Sources/SelectionBarCore/Models/SelectionBarActivationModifier.swift index 0161cb4..ef59894 100644 --- a/Sources/SelectionBarCore/Models/SelectionBarActivationModifier.swift +++ b/Sources/SelectionBarCore/Models/SelectionBarActivationModifier.swift @@ -1,6 +1,6 @@ import Foundation -/// Modifier key that can be required while selecting text to show Selection Bar. +/// Modifier key used to show Selection Bar for selected text. public enum SelectionBarActivationModifier: String, CaseIterable, Codable, Sendable { case command case option diff --git a/Sources/SelectionBarCore/Settings/SelectionBarSettingsStore.swift b/Sources/SelectionBarCore/Settings/SelectionBarSettingsStore.swift index 5f039a1..e4a4e47 100644 --- a/Sources/SelectionBarCore/Settings/SelectionBarSettingsStore.swift +++ b/Sources/SelectionBarCore/Settings/SelectionBarSettingsStore.swift @@ -78,7 +78,7 @@ public final class SelectionBarSettingsStore { } } - /// When enabled, Selection Bar appears only while the activation modifier is held. + /// When enabled, showing Selection Bar requires the activation modifier. public var selectionBarDoNotDisturbEnabled: Bool { didSet { persistIfNeeded() diff --git a/Tests/SelectionBarCoreTests/SelectionMonitorTests.swift b/Tests/SelectionBarCoreTests/SelectionMonitorTests.swift index 148a940..6fd7470 100644 --- a/Tests/SelectionBarCoreTests/SelectionMonitorTests.swift +++ b/Tests/SelectionBarCoreTests/SelectionMonitorTests.swift @@ -7,6 +7,173 @@ import Testing @Suite("SelectionMonitor Tests") @MainActor struct SelectionMonitorTests { + @Test( + "pressing the activation key shows an existing selection", + arguments: SelectionBarActivationModifier.allCases) + func activationKeyShowsExistingSelection(modifier: SelectionBarActivationModifier) async { + let accessibility = FakeSelectionMonitorAccessibility() + accessibility.selectedText = "selected range" + accessibility.focusedTextContext = true + let monitor = makeMonitor(accessibility: accessibility) + monitor.requireActivationModifier = true + monitor.requiredActivationModifier = modifier + monitor.start() + defer { monitor.stop() } + + var selections: [String] = [] + var locations: [NSPoint] = [] + monitor.onTextSelected = { text, location in + selections.append(text) + locations.append(location) + } + let location = NSPoint(x: 40, y: 80) + await monitor.handleModifierFlagsChanged( + [], at: location, frontmostBundleID: "com.example.Editor", frontmostPID: 42 + ) + monitor.handleMouseDown(at: .zero, clickCount: 1) + monitor.handleMouseUp(at: location, clickCount: 1, modifierFlags: []) + #expect(selections.isEmpty) + + await monitor.handleModifierFlagsChanged( + flags(for: modifier), at: location, frontmostBundleID: "com.example.Editor", frontmostPID: 42 + ) + #expect(selections == ["selected range"]) + #expect(locations == [location]) + } + + @Test("activation ignores other modifiers and rereads selection on the next press") + func activationOnlyTriggersOnConfiguredKeyPress() async { + let accessibility = FakeSelectionMonitorAccessibility() + accessibility.selectedText = "first selection" + accessibility.focusedTextContext = true + let monitor = makeMonitor(accessibility: accessibility) + monitor.requireActivationModifier = true + monitor.start() + defer { monitor.stop() } + + var selections: [String] = [] + var dismissals = 0 + monitor.onTextSelected = { text, _ in selections.append(text) } + monitor.onDismissRequested = { dismissals += 1 } + let location = NSPoint(x: 40, y: 80) + await monitor.handleModifierFlagsChanged( + .shift, at: location, frontmostBundleID: "com.example.Editor", frontmostPID: 42 + ) + #expect(selections.isEmpty) + + await monitor.handleModifierFlagsChanged( + .option, at: location, frontmostBundleID: "com.example.Editor", frontmostPID: 42 + ) + await monitor.handleModifierFlagsChanged( + [.option, .shift], at: location, frontmostBundleID: "com.example.Editor", frontmostPID: 42 + ) + await monitor.handleModifierFlagsChanged( + [], at: location, frontmostBundleID: "com.example.Editor", frontmostPID: 42 + ) + #expect(selections == ["first selection"]) + #expect(dismissals == 0) + + accessibility.selectedText = "second selection" + await monitor.handleModifierFlagsChanged( + .option, at: location, frontmostBundleID: "com.example.Editor", frontmostPID: 42 + ) + #expect(selections == ["first selection", "second selection"]) + } + + @Test("modifier presses do not query selections when Do Not Disturb is disabled") + func activationIsDisabledOutsideDoNotDisturb() async { + let accessibility = FakeSelectionMonitorAccessibility() + accessibility.selectedText = "selected range" + accessibility.focusedTextContext = true + let monitor = makeMonitor(accessibility: accessibility) + monitor.start() + defer { monitor.stop() } + + await monitor.handleModifierFlagsChanged( + .option, at: .zero, frontmostBundleID: "com.example.Editor", frontmostPID: 42 + ) + #expect(accessibility.selectedTextQueryCount == 0) + } + + @Test("activation key respects ignored apps and SelectionBar-owned UI", arguments: [false, true]) + func activationSkipsExcludedContexts(ownedBySelectionBar: Bool) async { + let accessibility = FakeSelectionMonitorAccessibility() + accessibility.selectedText = "selected range" + accessibility.focusedTextContext = true + accessibility.focusedElementOwnedByCurrentProcess = ownedBySelectionBar + let monitor = makeMonitor(accessibility: accessibility) + monitor.requireActivationModifier = true + if !ownedBySelectionBar { + monitor.ignoredBundleIDs = ["com.example.Editor"] + } + monitor.start() + defer { monitor.stop() } + + await monitor.handleModifierFlagsChanged( + .option, at: .zero, frontmostBundleID: "com.example.Editor", frontmostPID: 42 + ) + #expect(accessibility.selectedTextQueryCount == 0) + } + + @Test("activation key does not show a toolbar without selected text") + func activationRequiresSelectedText() async { + let accessibility = FakeSelectionMonitorAccessibility() + accessibility.focusedTextContext = true + let monitor = makeMonitor(accessibility: accessibility) + monitor.requireActivationModifier = true + monitor.start() + defer { monitor.stop() } + + var selections: [String] = [] + monitor.onTextSelected = { text, _ in selections.append(text) } + await monitor.handleModifierFlagsChanged( + .option, at: .zero, frontmostBundleID: "com.example.Editor", frontmostPID: 42 + ) + #expect(accessibility.selectedTextQueryCount == 1) + #expect(selections.isEmpty) + } + + @Test("activation key can read a selection through clipboard fallback") + func activationSupportsClipboardFallback() async { + let accessibility = FakeSelectionMonitorAccessibility() + accessibility.focusedTextContext = true + let clipboard = FakeSelectionMonitorClipboardFallback() + clipboard.selectedText = "clipboard selection" + let monitor = makeMonitor(accessibility: accessibility, clipboardFallback: clipboard) + monitor.requireActivationModifier = true + monitor.start() + defer { monitor.stop() } + + var selections: [String] = [] + monitor.onTextSelected = { text, _ in selections.append(text) } + await monitor.handleModifierFlagsChanged( + .option, at: .zero, frontmostBundleID: "com.example.Editor", frontmostPID: 42 + ) + #expect(clipboard.copyCount == 1) + #expect(selections == ["clipboard selection"]) + } + + @Test("stopping the monitor during clipboard fallback prevents a late popup") + func stoppingDuringActivationPreventsLatePopup() async { + let accessibility = FakeSelectionMonitorAccessibility() + accessibility.focusedTextContext = true + let clipboard = FakeSelectionMonitorClipboardFallback() + clipboard.selectedText = "clipboard selection" + let monitor = makeMonitor(accessibility: accessibility, clipboardFallback: clipboard) + monitor.requireActivationModifier = true + monitor.start() + defer { monitor.stop() } + + var selections: [String] = [] + monitor.onTextSelected = { text, _ in selections.append(text) } + clipboard.onCopy = { monitor.stop() } + await monitor.handleModifierFlagsChanged( + .option, at: .zero, frontmostBundleID: "com.example.Editor", frontmostPID: 42 + ) + #expect(clipboard.copyCount == 1) + #expect(selections.isEmpty) + } + @Test("mouse drag skips clipboard fallback without text selection signals") func mouseDragSkipsClipboardFallbackWithoutTextSelectionSignals() { let monitor = makeMonitor() @@ -245,15 +412,26 @@ struct SelectionMonitorTests { private func makeMonitor( accessibility: FakeSelectionMonitorAccessibility = FakeSelectionMonitorAccessibility(), + clipboardFallback: FakeSelectionMonitorClipboardFallback = + FakeSelectionMonitorClipboardFallback(), clipboardFallbackIncludedBundleIDs: Set = [] ) -> SelectionMonitor { let monitor = SelectionMonitor( accessibility: accessibility, - clipboardFallback: FakeSelectionMonitorClipboardFallback() + clipboardFallback: clipboardFallback ) monitor.clipboardFallbackIncludedBundleIDs = clipboardFallbackIncludedBundleIDs return monitor } + + private func flags(for modifier: SelectionBarActivationModifier) -> NSEvent.ModifierFlags { + switch modifier { + case .command: .command + case .option: .option + case .control: .control + case .shift: .shift + } + } } @MainActor @@ -264,6 +442,9 @@ private final class FakeSelectionMonitorAccessibility: SelectionMonitorAccessibi var hitTestTextContext = false var focusedTextContext = false var pointLikelyInFocusedWindowChrome = false + var selectedText: String? + var selectedTextQueryCount = 0 + var focusedElementOwnedByCurrentProcess = false @discardableResult func checkAccessibilityPermission(promptIfNeeded _: Bool) -> Bool { @@ -279,7 +460,8 @@ private final class FakeSelectionMonitorAccessibility: SelectionMonitorAccessibi } func selectedTextFromFocusedHierarchy() -> String? { - nil + selectedTextQueryCount += 1 + return selectedText } func hasFocusedTextSelection() -> Bool { @@ -303,7 +485,7 @@ private final class FakeSelectionMonitorAccessibility: SelectionMonitorAccessibi } func isFocusedElementOwnedByCurrentProcess() -> Bool { - false + focusedElementOwnedByCurrentProcess } func isFocusedTextContext() -> Bool { @@ -319,7 +501,13 @@ private final class FakeSelectionMonitorAccessibility: SelectionMonitorAccessibi private final class FakeSelectionMonitorClipboardFallback: SelectionMonitorClipboardFallbackProviding { + var selectedText: String? + var copyCount = 0 + var onCopy: (() -> Void)? + func selectedTextByCopyCommand() async -> String? { - nil + copyCount += 1 + onCopy?() + return selectedText } }