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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 4 additions & 4 deletions Sources/SelectionBarApp/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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": "啟用後,先選取文字,再按住所選按鍵以顯示選取工具列。"
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/SelectionBarApp/SelectionBarSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
}

Expand Down
121 changes: 101 additions & 20 deletions Sources/SelectionBarCore/Core/SelectionMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public final class SelectionMonitor {
/// Bundle IDs of apps where the selection bar should not appear
public var ignoredBundleIDs: Set<String> = []

/// 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.
Expand All @@ -57,13 +57,15 @@ 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
private let permissionGuide: SelectionMonitorPermissionGuiding

private var debounceTask: Task<Void, Never>?
private var isEnabled = false
private var previousModifierFlags: NSEvent.ModifierFlags = []

/// Track mouse-down location to detect drag vs click
private var mouseDownLocation: NSPoint?
Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -150,6 +155,7 @@ public final class SelectionMonitor {
return
}
isEnabled = true
previousModifierFlags = NSEvent.modifierFlags

let hasRequiredPermissions = requestRequiredPermissionsIfNeeded()
let trusted = checkAccessibilityPermission(promptIfNeeded: false)
Expand Down Expand Up @@ -195,6 +201,10 @@ public final class SelectionMonitor {
removeMonitors()
debounceTask?.cancel()
debounceTask = nil
previousModifierFlags = []
mouseDownLocation = nil
mouseDownClickCount = 1
clearMouseDownWindowOrigin()
logger.info("SelectionMonitor stopped")
}

Expand All @@ -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)
}
}

Expand All @@ -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
)
}
}

Expand All @@ -255,6 +276,7 @@ public final class SelectionMonitor {
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.debounceTask?.cancel()
self?.onDismissRequested?()
}
}
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading