Created
August 18, 2026 10:21
-
-
Save xidiot/438a8f385910318ccdc07d5bd713a276 to your computer and use it in GitHub Desktop.
macos_whitespace_move
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env swift | |
| import Foundation | |
| import AppKit | |
| import ApplicationServices | |
| func fail(_ message: String) -> Never { | |
| fputs("ERROR: \(message)\n", stderr) | |
| exit(1) | |
| } | |
| func axError(_ error: AXError) -> String { | |
| switch error { | |
| case .success: return "success" | |
| case .failure: return "failure" | |
| case .illegalArgument: return "illegalArgument" | |
| case .invalidUIElement: return "invalidUIElement" | |
| case .invalidUIElementObserver: return "invalidUIElementObserver" | |
| case .cannotComplete: return "cannotComplete" | |
| case .attributeUnsupported: return "attributeUnsupported" | |
| case .actionUnsupported: return "actionUnsupported" | |
| case .notificationUnsupported: return "notificationUnsupported" | |
| case .notImplemented: return "notImplemented" | |
| case .notificationAlreadyRegistered: return "notificationAlreadyRegistered" | |
| case .notificationNotRegistered: return "notificationNotRegistered" | |
| case .apiDisabled: return "apiDisabled" | |
| case .noValue: return "noValue" | |
| case .parameterizedAttributeUnsupported: return "parameterizedAttributeUnsupported" | |
| case .notEnoughPrecision: return "notEnoughPrecision" | |
| @unknown default: return "unknown(\(error.rawValue))" | |
| } | |
| } | |
| guard CommandLine.arguments.count == 2 else { | |
| fail("usage: whitespace-move previous|next") | |
| } | |
| let direction = CommandLine.arguments[1] | |
| guard direction == "previous" || direction == "next" else { | |
| fail("direction must be previous or next") | |
| } | |
| // Check Accessibility permission. | |
| let trusted = AXIsProcessTrusted() | |
| print("Accessibility trusted: \(trusted)") | |
| guard trusted else { | |
| fail(""" | |
| This process is NOT trusted for Accessibility. | |
| Give the application launching this binary Accessibility permission | |
| in System Settings → Privacy & Security → Accessibility. | |
| """) | |
| } | |
| // Get the focused element. | |
| let workspace = NSWorkspace.shared | |
| guard let app = workspace.frontmostApplication else { | |
| fail("No frontmost application") | |
| } | |
| print("Frontmost app: \(app.localizedName ?? "?")") | |
| print("PID: \(app.processIdentifier)") | |
| let appElement = AXUIElementCreateApplication(app.processIdentifier) | |
| var focusedRef: CFTypeRef? | |
| let focusedResult = AXUIElementCopyAttributeValue( | |
| appElement, | |
| kAXFocusedUIElementAttribute as CFString, | |
| &focusedRef | |
| ) | |
| print("Focused element result: \(axError(focusedResult))") | |
| guard focusedResult == .success, | |
| let focusedRef else { | |
| fail("Could not get focused UI element") | |
| } | |
| let element = focusedRef as! AXUIElement | |
| // Print role. | |
| var roleRef: CFTypeRef? | |
| let roleResult = AXUIElementCopyAttributeValue( | |
| element, | |
| kAXRoleAttribute as CFString, | |
| &roleRef | |
| ) | |
| print("Role result: \(axError(roleResult))") | |
| print("Role: \(roleRef ?? "nil" as CFTypeRef)") | |
| // Get text. | |
| var valueRef: CFTypeRef? | |
| let valueResult = AXUIElementCopyAttributeValue( | |
| element, | |
| kAXValueAttribute as CFString, | |
| &valueRef | |
| ) | |
| print("Value result: \(axError(valueResult))") | |
| guard valueResult == .success, | |
| let text = valueRef as? String else { | |
| fail("Focused element does not expose text through kAXValueAttribute") | |
| } | |
| print("Text length: \(text.utf16.count)") | |
| print("Text: \(text)") | |
| // Get caret/selection. | |
| var rangeRef: CFTypeRef? | |
| let rangeResult = AXUIElementCopyAttributeValue( | |
| element, | |
| kAXSelectedTextRangeAttribute as CFString, | |
| &rangeRef | |
| ) | |
| print("Selected range result: \(axError(rangeResult))") | |
| guard rangeResult == .success, | |
| let rangeRef else { | |
| fail("Focused element does not expose kAXSelectedTextRangeAttribute") | |
| } | |
| let axRange = rangeRef as! AXValue | |
| var selectedRange = CFRange() | |
| guard AXValueGetValue( | |
| axRange, | |
| .cfRange, | |
| &selectedRange | |
| ) else { | |
| fail("Could not decode selected text range") | |
| } | |
| print( | |
| "Selection: location=\(selectedRange.location), length=\(selectedRange.length)" | |
| ) | |
| let caret: Int | |
| if selectedRange.length == 0 { | |
| caret = selectedRange.location | |
| } else if direction == "previous" { | |
| caret = selectedRange.location | |
| } else { | |
| caret = selectedRange.location + selectedRange.length | |
| } | |
| let nsText = text as NSString | |
| let length = nsText.length | |
| func isWhitespace(_ index: Int) -> Bool { | |
| guard index >= 0 && index < length else { | |
| return false | |
| } | |
| let scalar = UnicodeScalar(nsText.character(at: index))! | |
| return CharacterSet.whitespacesAndNewlines.contains(scalar) | |
| } | |
| var target: Int? | |
| if direction == "previous" { | |
| var i = min(caret - 1, length - 1) | |
| // Skip whitespace immediately behind us. | |
| while i >= 0 && isWhitespace(i) { | |
| i -= 1 | |
| } | |
| // Walk backwards to the start of this token. | |
| while i >= 0 && !isWhitespace(i) { | |
| i -= 1 | |
| } | |
| target = i + 1 | |
| } else { | |
| var i = max(caret, 0) | |
| // Skip whitespace immediately ahead. | |
| while i < length && isWhitespace(i) { | |
| i += 1 | |
| } | |
| // Walk forwards to the end of this token. | |
| while i < length && !isWhitespace(i) { | |
| i += 1 | |
| } | |
| target = i | |
| } | |
| guard let target else { | |
| print("No whitespace boundary found") | |
| exit(0) | |
| } | |
| print("Moving from \(caret) → \(target)") | |
| var newRange = CFRange( | |
| location: target, | |
| length: 0 | |
| ) | |
| guard let newAXRange = AXValueCreate( | |
| .cfRange, | |
| &newRange | |
| ) else { | |
| fail("Could not create AX range") | |
| } | |
| let setResult = AXUIElementSetAttributeValue( | |
| element, | |
| kAXSelectedTextRangeAttribute as CFString, | |
| newAXRange | |
| ) | |
| print("Set range result: \(axError(setResult))") | |
| guard setResult == .success else { | |
| fail("Could not move cursor") | |
| } | |
| print("SUCCESS") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment