Move Mouse to Cursor/Selected Text

This action will move the mouse pointer to the text cursor position. If text is selected, it will move to the selection. A tall, multi-line selection aims at the first visible line, rather the middle of the block.

This works in Cocoa editors like TextEdit, Mail, Notes, Pages, Xcode, BBEdit, and most NSTextView fields. It does not work in Electron or custom editors (VS Code, Cursor, Slack, Sublime), as those never publish AXBoundsForRange.

Move Mouse to Selected Text.kmactions (9.0 KB)

8 Likes

I thought that I'd mention that it also works in Python apps.

1 Like

Thanks for letting me know. That's a plus!

Does it work in any browsers?

(I'm not able to test right now, but this is a decidedly useful thing.)

Thank for sharing this, @noisneil.

For me, with BBEdit v16.0.2 , this works as expected if I've selected some text (e.g., by double-clicking a word).

But if I click somewhere to place the text insertion cursor within some text, it does not. The pointer moves, but not to the cursor.

Thus far, I haven't tried other editors except this one I'm using to create the post–Discourse editor, Arc Browser. With this editor, neither work.


• Keyboard Maestro 11.1.1
• Tahoe 26.6.1 (25G76)/MacBookPro18,2

It's working in Brave for me, but only for fields you can type in, like this comment box, search fields etc...

Strange, it's working fine for me. (Tahoe 26.5.2, M4 Max)

CleanShot 2026-08-26 at 08.18.41)

Thank for sharing this, @noisneil.

For me, with BBEdit v16.0.2 , this works as expected if I've select some text (e.g., by double-clicking a word).

But if I click somewhere to place the text insertion cursor within some text, it does not. The pointer moves, but not to the cursor.

Thus far, I haven't tried other editors except this one I'm using to create the post–Discourse editor, Arc Browser. With this editor, neither work.


• Keyboard Maestro 11.1.1
• Tahoe 26.6.1 (25G76)/MacBookPro18,2

Move Pointer

Obvious difference -- unlike @noisneil, you've got soft-wrap turned on. Does it work if you turn that off?

Hi, @Nige_S. Good thought, but no difference. You did get me thinking, so I tried this:

  1. Restarted my mac, no difference.
  2. Restarted my mac with macOS Safe Boot and it worked as expected.
  3. Restarted in normal mode and it's continuing to work.

Go figure!

2 Likes

In BBEdit 16.0.3, it does work as expected (if that helps).

1 Like

Is this the script which starts with:

#!/usr/bin/swift
import ApplicationServices
import AppKit
import Foundation
import CoreGraphics

it doesn't work on macOS Tahoe 26.4

Also, after download, I had problems to find it in my numerous groups.

/okn

Do you have Command Line Tools installed on your Mac? This is a requirement of Swift.

Here's the script in full:

Script
#!/usr/bin/swift
import ApplicationServices
import AppKit
import Foundation
import CoreGraphics

// Move the pointer to the currently selected text in the front text editor.
// If nothing is selected, moves to the insertion point (caret).
//
// Works in AppKit / TextKit editors that implement AXBoundsForRange:
//   TextEdit, Mail, Notes, Stickies, Pages, Xcode, BBEdit, many NSTextView fields.
// Does not work in Electron / custom editors (VS Code, Cursor, Slack, Sublime, etc.).

func fail(_ message: String) -> Never {
    fputs(message + "\n", stderr)
    print("ERROR: \(message)")
    exit(1)
}

func axCopy(_ element: AXUIElement, _ attribute: String) -> CFTypeRef? {
    var ref: CFTypeRef?
    guard AXUIElementCopyAttributeValue(element, attribute as CFString, &ref) == .success else { return nil }
    return ref
}

func axString(_ element: AXUIElement, _ attribute: String) -> String? {
    guard let ref = axCopy(element, attribute) else { return nil }
    if let string = ref as? String {
        let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? nil : trimmed
    }
    return nil
}

func asAXElement(_ value: Any) -> AXUIElement? {
    let cf = value as CFTypeRef
    guard CFGetTypeID(cf) == AXUIElementGetTypeID() else { return nil }
    return (value as! AXUIElement)
}

func axElement(_ element: AXUIElement, _ attribute: String) -> AXUIElement? {
    guard let ref = axCopy(element, attribute) else { return nil }
    return asAXElement(ref)
}

func axList(_ element: AXUIElement, _ attribute: String) -> [AXUIElement] {
    guard let ref = axCopy(element, attribute) else { return [] }
    if let array = ref as? [Any] { return array.compactMap(asAXElement) }
    if let array = ref as? NSArray { return array.compactMap { asAXElement($0) } }
    return []
}

func axRange(_ element: AXUIElement, _ attribute: String) -> CFRange? {
    guard let ref = axCopy(element, attribute) else { return nil }
    let value = ref as! AXValue
    var range = CFRange()
    guard AXValueGetValue(value, .cfRange, &range) else { return nil }
    return range
}

func axRect(_ element: AXUIElement, _ attribute: String) -> CGRect? {
    guard let ref = axCopy(element, attribute) else { return nil }
    let value = ref as! AXValue
    var rect = CGRect.zero
    guard AXValueGetValue(value, .cgRect, &rect) else { return nil }
    return rect
}

func axParent(_ element: AXUIElement) -> AXUIElement? {
    axElement(element, kAXParentAttribute as String)
}

func parameterizedNames(_ element: AXUIElement) -> [String] {
    var ref: CFArray?
    AXUIElementCopyParameterizedAttributeNames(element, &ref)
    return (ref as? [String]) ?? []
}

func boundsForRange(_ element: AXUIElement, _ range: CFRange) -> CGRect? {
    var range = range
    guard range.location >= 0, range.length >= 0,
          let axRange = AXValueCreate(.cfRange, &range) else { return nil }
    var ref: CFTypeRef?
    let err = AXUIElementCopyParameterizedAttributeValue(
        element,
        kAXBoundsForRangeParameterizedAttribute as CFString,
        axRange,
        &ref
    )
    guard err == .success, let ref else { return nil }
    let value = ref as! AXValue
    var rect = CGRect.zero
    guard AXValueGetValue(value, .cgRect, &rect) else { return nil }
    return rect
}

func intersect(_ a: CFRange, _ b: CFRange) -> CFRange? {
    let start = max(a.location, b.location)
    let end = min(a.location + a.length, b.location + b.length)
    guard start < end else { return nil }
    return CFRange(location: start, length: end - start)
}

func usableRect(_ rect: CGRect?) -> CGRect? {
    guard let rect, rect.width.isFinite, rect.height.isFinite else { return nil }
    guard rect.width > 0 || rect.height > 0 else { return nil }
    return rect
}

func caretRange(_ selected: CFRange) -> CFRange {
    if selected.length > 0 { return selected }
    if selected.location > 0 {
        return CFRange(location: selected.location - 1, length: 1)
    }
    return CFRange(location: selected.location, length: 1)
}

func selectionRect(on element: AXUIElement) -> CGRect? {
    guard parameterizedNames(element).contains(kAXBoundsForRangeParameterizedAttribute as String),
          let selected = axRange(element, kAXSelectedTextRangeAttribute as String) else {
        return nil
    }
    let target: CFRange
    if selected.length > 0, let visible = axRange(element, kAXVisibleCharacterRangeAttribute as String),
       let visibleSelection = intersect(selected, visible) {
        target = visibleSelection
    } else {
        target = caretRange(selected)
    }
    if let rect = usableRect(boundsForRange(element, target)) {
        // Tall multi-line selections: aim at the first visible line, not the middle.
        if rect.height > 36, target.length > 1 {
            let first = CFRange(location: target.location, length: 1)
            if let line = usableRect(boundsForRange(element, first)) { return line }
        }
        return rect
    }
    return usableRect(boundsForRange(element, caretRange(selected)))
}

func role(_ element: AXUIElement) -> String {
    axString(element, kAXRoleAttribute as String) ?? ""
}

func lookupSelection(from start: AXUIElement) -> (AXUIElement, CGRect)? {
    var current: AXUIElement? = start
    for _ in 0..<16 {
        guard let node = current else { break }
        if let rect = selectionRect(on: node) { return (node, rect) }
        current = axParent(node)
    }
    return nil
}

func isRunnerApp(_ app: NSRunningApplication) -> Bool {
    if app.processIdentifier == ProcessInfo.processInfo.processIdentifier { return true }
    guard let bid = app.bundleIdentifier else { return false }
    return bid.hasPrefix("com.stairways.keyboardmaestro")
}

func runningApp(for axApp: AXUIElement) -> NSRunningApplication? {
    var pid: pid_t = 0
    guard AXUIElementGetPid(axApp, &pid) == .success else { return nil }
    return NSRunningApplication(processIdentifier: pid)
}

func movePointer(to point: CGPoint) {
    CGWarpMouseCursorPosition(point)
    CGAssociateMouseAndMouseCursorPosition(1)
    let source = CGEventSource(stateID: .hidSystemState)
    if let move = CGEvent(mouseEventSource: source, mouseType: .mouseMoved, mouseCursorPosition: point, mouseButton: .left) {
        move.post(tap: .cghidEventTap)
    }
}

guard AXIsProcessTrusted() else {
    fail("Grant Accessibility permission to the app running this script (Keyboard Maestro Engine or Terminal).")
}

let systemWide = AXUIElementCreateSystemWide()
var found: (AXUIElement, CGRect)?

if let focused = axElement(systemWide, kAXFocusedUIElementAttribute as String) {
    found = lookupSelection(from: focused)
}

if found == nil, let axApp = axElement(systemWide, kAXFocusedApplicationAttribute as String),
   let app = runningApp(for: axApp), !isRunnerApp(app),
   let focused = axElement(axApp, kAXFocusedUIElementAttribute as String) {
    found = lookupSelection(from: focused)
}

if found == nil {
    let apps = NSWorkspace.shared.runningApplications
        .filter { $0.activationPolicy == .regular && !$0.isTerminated && !isRunnerApp($0) }
        .sorted { ($0.isActive ? 1 : 0) > ($1.isActive ? 1 : 0) }
    var caretFallback: (AXUIElement, CGRect)?
    for app in apps {
        let axApp = AXUIElementCreateApplication(app.processIdentifier)
        let focused = axElement(axApp, kAXFocusedUIElementAttribute as String)
            ?? axElement(axApp, kAXFocusedWindowAttribute as String)
        guard let start = focused, let hit = lookupSelection(from: start) else { continue }
        let selected = axRange(hit.0, kAXSelectedTextRangeAttribute as String)
        if let selected, selected.length > 0 {
            found = hit
            break
        }
        if caretFallback == nil { caretFallback = hit }
    }
    if found == nil { found = caretFallback }
}

guard let (element, rect) = found else {
    fail("No selected text (or caret) with a screen position. This editor may not expose AXBoundsForRange.")
}

let point = CGPoint(x: rect.midX, y: rect.midY)
movePointer(to: point)

let selectedText = axString(element, kAXSelectedTextAttribute as String)
if let selectedText {
    let snippet = selectedText.count > 80 ? String(selectedText.prefix(77)) + "..." : selectedText
    print("\(Int(point.x.rounded())),\(Int(point.y.rounded()))  \(snippet)")
} else {
    print("\(Int(point.x.rounded())),\(Int(point.y.rounded()))")
}

Them's the breaks. Given that you have numerous groups, I'm sure you've downloaded macros before. Not much anyone can do about that, I'm afraid.

Recently I uninstalled the CLT as I'm not a developer.
(The frequent upgrade notifications were the reason.)

Never mind, there are plenty of other useful macros :slight_smile:

/okn