~12 min · axuielement, focused-element, selected-text, range
Level 0Cold Flint
0 XP0/34 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"Start at the whole system, narrow to the focused element, ask it for the words. Three hops, and you have the input."
System-Wide to Focused
The read is a short chain of attribute queries. You create the system-wide element, then copy its kAXFocusedUIElementAttribute — that gives you whichever UI element currently has keyboard focus, in whatever app is frontmost. You didn't have to know which app it is; AX resolves 'the focused thing' for you. That focused element is the node you'll interrogate for the selection.
Asking for the Selection
From the focused element you copy two more attributes: kAXSelectedTextAttribute, which comes back as a string of the highlighted text, and kAXSelectedTextRangeAttribute, which comes back as an AXValue wrapping a range (offset and length). You want both. The text is your {input}; the range is what you'll need later to put the result back exactly where the selection was. Capturing the range now, at read time, is what makes precise replacement possible after the round trip.
system-wide element
copy kAXFocusedUIElementAttribute -> the focused element
copy kAXSelectedTextAttribute -> "the highlighted text"
copy kAXSelectedTextRangeAttribute -> range { offset, length }
Text becomes {input}. Range is remembered for insertion.
When There's No Selection
Not every strike has a selection under it — sometimes the cursor is just sitting in a field. That's a valid state, not an error. Flint's rule is explicit: when there's selected text, it becomes {input} and the result replaces it; when there isn't, the input is empty and the macro's output is inserted at the cursor instead of replacing anything. Reading the selection has to report the difference between 'empty selection' and 'no focused element at all,' because those lead to two very different behaviors downstream.
Copy the range at read time, not insertion time. It's tempting to grab only the selected text and re-derive the range later when you insert. Don't — by insertion time the selection may be gone. The range you captured at the strike is a fact about the moment of intent; re-reading it after the async round trip reads a different, possibly-moved world. Capture text and range together, up front.
The role tells you what kind of field you're in. While you're here, also copy kAXRoleAttribute and kAXSubroleAttribute. They classify the target — a text area, a search field, a secure field — which matters for both how you insert later and, critically, for refusing to touch secure fields at all (Track 7). One extra query at read time saves you from guessing later.
Code
The read: focused element, then selected text and range·swift
func readSelection() -> (text: String?, range: CFRange?, element: AXUIElement)? {
let system = AXUIElementCreateSystemWide()
// 1. The focused element, whichever app it lives in.
var focusedRef: CFTypeRef?
guard AXUIElementCopyAttributeValue(
system, kAXFocusedUIElementAttribute as CFString, &focusedRef
) == .success else { return nil } // nil = no focused element
let element = focusedRef as! AXUIElement
// 2. The selected text (may be empty — a valid state, not a failure).
var textRef: CFTypeRef?
let text = AXUIElementCopyAttributeValue(
element, kAXSelectedTextAttribute as CFString, &textRef
) == .success ? (textRef as? String) : nil
// 3. The range, wrapped in an AXValue. Needed later for insertion.
var range: CFRange? = nil
var rangeRef: CFTypeRef?
if AXUIElementCopyAttributeValue(
element, kAXSelectedTextRangeAttribute as CFString, &rangeRef
) == .success, let v = rangeRef, CFGetTypeID(v) == AXValueGetTypeID() {
var r = CFRange()
if AXValueGetValue(v as! AXValue, .cfRange, &r) { range = r }
}
return (text, range, element)
}
Trace what your read function returns in three cases: (1) text is highlighted in a native editor, (2) the cursor is in a field but nothing is selected, (3) no element has focus at all. For each, say what {input} is and what the strike should do. Why must your return type distinguish 'empty selection' from 'no focused element'?
Hint
Case 1: text and range are present, {input} is the text, the result replaces the selection. Case 2: focused element exists but selection is empty, {input} is empty, output is inserted at the cursor. Case 3: no focused element, there's nothing safe to act on. The distinction matters because empty-selection is a normal insert-at-cursor path, while no-focused-element means the strike has no valid target and should not proceed.
Progress
Progress is local-only — sign in to sync across devices.