I'm building yabbistir, a clipboard history for macOS. Its one trick is where it opens: press ⇧⌘V and the panel appears 8 points under the text caret of whatever app you're typing in. You type to search, press ↵, and the item is pasted back into that app. Your eyes don't travel and your hands don't leave the keyboard.
That needs three things macOS doesn't hand you directly:
- the caret's position in someone else's app,
- a sensible answer when that app won't tell you,
- a window that takes keystrokes without taking focus away from the app you're typing in.
Here is how each one works, with code from the app, shortened.
Asking Accessibility where the caret is
There's no "where is the caret" API on macOS. There is the Accessibility API, the one assistive apps like VoiceOver use to read other apps' interfaces. Your app needs the Accessibility permission for it (AXIsProcessTrusted() tells you whether you have it), and then it can ask three questions:
- Which element has keyboard focus? Ask the system-wide element for
kAXFocusedUIElementAttribute. - What's selected in it?
kAXSelectedTextRangeAttributereturns aCFRange. With nothing selected, it's an empty range at the caret. - Where is that range on screen? The parameterized attribute
kAXBoundsForRangeParameterizedAttributetakes the range and returns a rectangle.
func caretRect(primaryScreenMaxY: CGFloat) -> CGRect? { let systemWide = AXUIElementCreateSystemWide() AXUIElementSetMessagingTimeout(systemWide, 0.1) guard let element = uiElement(value(of: kAXFocusedUIElementAttribute, on: systemWide)), let rangeValue = axValue(value(of: kAXSelectedTextRangeAttribute, on: element)) else { return nil } var range = CFRange() guard AXValueGetValue(rangeValue, .cfRange, &range), let rect = caretBounds(range, in: element) else { return nil } return CGRect(x: rect.minX, y: primaryScreenMaxY - rect.maxY, width: max(rect.width, 1), height: rect.height) } The helpers are thin wrappers. Every value comes back as a CFTypeRef, so check its type before casting:
func value(of attribute: String, on element: AXUIElement) -> CFTypeRef? { var value: CFTypeRef? guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { return nil } return value } func axValue(_ value: CFTypeRef?) -> AXValue? { guard let value, CFGetTypeID(value) == AXValueGetTypeID() else { return nil } return (value as! AXValue) } func bounds(_ range: CFRange, in element: AXUIElement) -> CGRect? { var range = range guard let parameter = AXValueCreate(.cfRange, &range) else { return nil } var value: CFTypeRef? guard AXUIElementCopyParameterizedAttributeValue( element, kAXBoundsForRangeParameterizedAttribute as CFString, parameter, &value) == .success, let rectValue = axValue(value) else { return nil } var rect = CGRect.zero guard AXValueGetValue(rectValue, .cgRect, &rect), rect.height > 0, rect != .zero else { return nil } return rect } uiElement(_:) is the same check with AXUIElementGetTypeID().
Three lines in caretRect carry more weight than they look.
The timeout. Every Accessibility call is a message to the other app, and it waits for an answer. If that app is busy, say an IDE in the middle of a beachball, the default wait is around six seconds. Setting the timeout on the system-wide element applies it to every Accessibility call your process makes. At 0.1 seconds, a hung app costs a blink, and the panel opens at the fallback instead.
The coordinate flip. Accessibility rectangles have their origin at the top left of the primary screen, with y growing downward. Cocoa window frames have their origin at the bottom left, with y growing upward. Flip with the primary screen's height, which is NSScreen.screens.first. Don't use NSScreen.main here: that's the screen with the key window, and it changes as you work.
Width 1. An empty range gives a zero-width rectangle. max(rect.width, 1) keeps it a real rectangle for the placement math later.
When the answer is wrong
Two cases need special handling, and both are apps that answer, just not usefully.
Some apps, Terminal among them, answer the empty range at the caret with an empty rectangle. The caret then sits right after the previous character, so ask for that character's bounds and use its right edge. At the very start of the text, use the next character's left edge:
func caretBounds(_ range: CFRange, in element: AXUIElement) -> CGRect? { if let rect = bounds(range, in: element) { return rect } guard range.length == 0 else { return nil } if range.location > 0, let previous = bounds(CFRange(location: range.location - 1, length: 1), in: element) { return CGRect(x: previous.maxX, y: previous.minY, width: 1, height: previous.height) } if let next = bounds(CFRange(location: range.location, length: 1), in: element) { return CGRect(x: next.minX, y: next.minY, width: 1, height: next.height) } return nil } The other case is a caret you can't see. A range scrolled out of view, like a spot in Terminal's scrollback, still reports a rectangle, somewhere off in space. So caretRect also reads the focused element's own frame (kAXPositionAttribute and kAXSizeAttribute) and throws the caret away if it isn't inside it, with a few points of slack:
if let frame = frame(of: element), !frame.insetBy(dx: -4, dy: -4).contains(CGPoint(x: rect.minX, y: rect.midY)) { return nil } Apps that don't report a caret
Cocoa text views, Safari and Notes answer all of this. Electron and Chromium apps don't, unless their accessibility mode is on. Electron apps can be switched into it from outside through an AXManualAccessibility attribute, but that changes how someone else's app runs, so yabbistir leaves it to the user.
When there's no caret, the panel opens under the mouse pointer. The fallback is shaped like a caret, 1 point wide and 18 points tall, so the placement code never has to know which one it got:
func mouseAnchor() -> CGRect { let point = NSEvent.mouseLocation return CGRect(x: point.x, y: point.y - 18, width: 1, height: 18) } The choice between them is a small pure function. An explicit anchor wins (clicking the menu bar icon passes the icon's rectangle), and a setting lets people always open at the pointer. The closures mean the Accessibility calls only happen when they're needed:
enum PanelPosition { case caret, pointer } func anchor(position: PanelPosition, explicit: CGRect?, caret: () -> CGRect?, pointer: () -> CGRect) -> CGRect { if let explicit { return explicit } switch position { case .pointer: return pointer() case .caret: return caret() ?? pointer() } } Placing the panel
The rule: open below the caret with an 8-point gap. If it only fits above, open above. If it fits on neither side, take the roomier side and shrink, but never below 260 points. That way the panel never covers the caret unless the screen is too short for even the minimum.
let roomBelow = caret.minY - gap - (visibleFrame.minY + margin) let roomAbove = (visibleFrame.maxY - margin) - (caret.maxY + gap) let height: CGFloat let opensAbove: Bool if panelSize.height <= roomBelow { (height, opensAbove) = (panelSize.height, false) } else if panelSize.height <= roomAbove { (height, opensAbove) = (panelSize.height, true) } else if roomAbove > roomBelow { (height, opensAbove) = (max(minHeight, roomAbove), true) } else { (height, opensAbove) = (max(minHeight, roomBelow), false) } visibleFrame comes from the screen that contains the caret, so the menu bar and the Dock are already left out. Horizontally, the panel starts 24 points left of the caret and is clamped to the screen with a 12-point margin. Because it's all plain rectangles in and a rectangle out, the tests don't need a window or a screen.
A panel that takes the keyboard but not the focus
This is the part that's easy to get wrong. If the panel were a normal window, showing it would activate yabbistir, the app you were typing in would stop being the active app, and the paste would land in the wrong place. The panel has to receive your keystrokes for search while the other app stays active. That's what a non-activating NSPanel is for:
final class PanelWindow: NSPanel { init() { super.init(contentRect: .zero, styleMask: [.nonactivatingPanel, .borderless, .fullSizeContentView], backing: .buffered, defer: true) isFloatingPanel = true level = .floating isOpaque = false backgroundColor = .clear hasShadow = true hidesOnDeactivate = false becomesKeyOnlyIfNeeded = false isMovable = false collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle] } override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { false } } -
.nonactivatingPanellets the panel become the key window without activating its app. - A borderless window can't become key by default, so
canBecomeKeyis overridden. That's what lets the search field receive typing. -
becomesKeyOnlyIfNeeded = falsemakes it key as soon as it appears, not only when a control that needs the keyboard is clicked. -
hidesOnDeactivate = falsebecause anNSPanelhides by default whenever its app deactivates. This panel closes by its own rule instead, shown below. -
.canJoinAllSpacesand.fullScreenAuxiliarylet it open on the current Space, even over a full-screen app..transientand.ignoresCyclekeep it out of Mission Control and window cycling.
The app itself runs as an accessory (NSApp.setActivationPolicy(.accessory)), with no Dock icon. Showing the panel remembers the frontmost app first, then orders the panel in without activating anything:
targetApp = NSWorkspace.shared.frontmostApplication window.setFrame(placed.frame, display: true) window.orderFrontRegardless() window.makeKey() orderFrontRegardless() brings a window forward even when its app isn't active, which is exactly the situation here.
Closing it when you click somewhere else takes one more step. When the panel resigns key, the new key window isn't known yet, so yabbistir decides on the next turn of the run loop:
func windowDidResignKey(_ notification: Notification) { DispatchQueue.main.async { [weak self] in guard let self, NSApp.keyWindow == nil, window.isVisible else { return } hide() } } If focus moved to another app, NSApp.keyWindow is nil and the panel closes. If it moved into one of yabbistir's own windows, like the category editor, the panel stays.
Pasting back
Once you press ↵, the panel hides, the item goes on the pasteboard, and yabbistir waits 30 milliseconds so the target app can become key again. Then it checks that the frontmost app is still the one the panel opened for, and posts ⌘V:
let source = CGEventSource(stateID: .combinedSessionState) for keyDown in [true, false] { let event = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: keyDown) event?.flags = .maskCommand event?.post(tap: .cghidEventTap) } keyCode isn't hard-coded to kVK_ANSI_V. On Dvorak or Turkish F, the key that types "v" is somewhere else, and ⌘ shortcuts follow the layout. yabbistir asks the current ASCII-capable layout (TISCopyCurrentASCIICapableKeyboardLayoutInputSource plus UCKeyTranslate) which key code produces "v".
Posting events needs the Accessibility permission too. Without it, the item stays on the clipboard and a notice asks you to press ⌘V yourself, so nothing is lost.
Testing it, and a note on permissions
The Accessibility calls go through a small protocol, so tests can stand in for the focused app:
protocol AXQuerying { func setMessagingTimeout(_ element: AXUIElement, seconds: Float) func value(of attribute: String, on element: AXUIElement) -> CFTypeRef? func value(of attribute: String, on element: AXUIElement, parameter: CFTypeRef) -> CFTypeRef? } With a fake behind it, Terminal's empty rectangle, the scrolled-out caret, the coordinate flip and "the timeout is set before the first question" are each a short unit test.
One practical tip if you build something like this: macOS ties the Accessibility grant to the app's code signature. An ad hoc signed debug build gets a new signature on every build, so the grant resets every time. A self-signed Code Signing certificate from Keychain Access keeps it across rebuilds.
The code
All of this is in yabbistir's repository, MIT licensed: CaretLocator.swift, PanelPlacement.swift and PanelWindow.swift are the files to start with. If you know an app where the caret lookup fails and shouldn't, I'd like to hear about it.
