@@ -29,6 +29,17 @@ struct FocusSnapshotResolver {
2929 /// Carries deep-walk throttle state across the value-typed resolver's non-mutating polls.
3030 private let deepWalkThrottle = DeepGeometryWalkThrottle ( )
3131
32+ /// Same lifetime trick for the Branch 2.5 static-text-run walk: collected run frames are
33+ /// reused across polls of one field instead of re-walking up to ~300 nodes per tick.
34+ private let staticRunWalkThrottle = StaticTextRunWalkThrottle ( )
35+
36+ /// Session-scoped caches for AX reads that are invariant while focus stays in one field.
37+ /// Secure-field verdicts gate whether Cotabby operates at all, so they are scoped to the
38+ /// focus-change sequence rather than raw element identity, which CFHash can recycle across
39+ /// fields (see `FocusSessionScopedCache`).
40+ private let secureFieldVerdictCache = FocusSessionScopedCache < Bool > ( )
41+ private let terminalDetectionCache = FocusSessionScopedCache < Bool > ( )
42+
3243 /// Caches the resolved field font/color per focused element so the attributed-string AX read
3344 /// happens once per field rather than on every poll. Reference type for the same reason as
3445 /// `deepWalkThrottle`: it carries state across the value-typed resolver's non-mutating polls.
@@ -72,9 +83,14 @@ struct FocusSnapshotResolver {
7283 let deepDescendants = BrowserAppDetector . needsWebAccessibilityPriming (
7384 bundleIdentifier: bundleIdentifier)
7485 let candidateResolution = resolveCandidate (
75- around: focusedElement,
86+ around: FocusedElementReading (
87+ element: focusedElement,
88+ role: focusedRole,
89+ subrole: focusedSubrole
90+ ) ,
7691 bundleIdentifier: bundleIdentifier,
77- deepDescendants: deepDescendants
92+ deepDescendants: deepDescendants,
93+ focusChangeSequence: focusChangeSequence
7894 )
7995 let resolution = candidateResolution. resolution
8096 let diagnosticCandidate = candidateResolution. diagnosticCandidate
@@ -221,11 +237,18 @@ struct FocusSnapshotResolver {
221237 // terminal while leaving the editor and chat working. Read on the focused element because
222238 // that is exactly where xterm puts the caret (`xterm-helper-textarea`). Computed here — only
223239 // once a real editable field has resolved — so idle/non-editable focus polls don't pay for an
224- // extra AXDOMClassList round-trip; native apps don't vend the attribute anyway.
225- let isIntegratedTerminal = TerminalAppDetector . isIntegratedTerminal (
226- domClassList: AXHelper . stringArrayValue (
227- for: " AXDOMClassList " as CFString , on: focusedElement) ?? [ ]
228- )
240+ // extra AXDOMClassList round-trip; native apps don't vend the attribute anyway. Cached per
241+ // focus session because the class list on one focused element cannot change without a field
242+ // switch bumping the sequence, which previously cost one round-trip on every poll tick.
243+ let isIntegratedTerminal = terminalDetectionCache. value (
244+ forKey: focusedElementIdentifier,
245+ focusChangeSequence: focusChangeSequence
246+ ) {
247+ TerminalAppDetector . isIntegratedTerminal (
248+ domClassList: AXHelper . stringArrayValue (
249+ for: " AXDOMClassList " as CFString , on: focusedElement) ?? [ ]
250+ )
251+ }
229252 // Web-vs-native classification for the caret-geometry trust policy. The DOM-attribute
230253 // signal was computed in `candidateSnapshot` from the attribute list it already fetched,
231254 // so this adds no AX round-trip to the focus poll.
@@ -294,32 +317,71 @@ struct FocusSnapshotResolver {
294317 /// reading text/selection/caret data from many wrapper and static-text nodes even after the real
295318 /// input target had already been discovered. This preserves the resolver's "first full
296319 /// capability wins" policy while avoiding unnecessary synchronous AX IPC.
320+ ///
321+ /// Candidate enumeration is staged the same way: the bounded descendant BFS used for Chromium
322+ /// wrappers costs hundreds of additional AX round trips per pass, and a shallow candidate
323+ /// (focused node, ancestors, their children) wins in the common case — including Chromium
324+ /// hosts that focus the editable directly — so the BFS runs only when no shallow candidate
325+ /// resolves with full capabilities. Evaluation order is unchanged: shallow candidates always
326+ /// preceded BFS appends, so any shallow winner made the BFS results unreachable anyway.
297327 private func resolveCandidate(
298- around focusedElement : AXUIElement ,
328+ around focusedReading : FocusedElementReading ,
299329 bundleIdentifier: String ,
300- deepDescendants: Bool
330+ deepDescendants: Bool ,
331+ focusChangeSequence: UInt64
301332 ) -> FocusCandidateResolution {
302333 var bestPartial : ( candidate: AXFocusCandidate , evaluation: FocusCapabilityCandidateEvaluation ) ?
303334 var inspectedCount = 0
304335
305- for element in candidateElements ( around: focusedElement, deepDescendants: deepDescendants) {
306- inspectedCount += 1
307- let candidate = candidateSnapshot ( for: element, bundleIdentifier: bundleIdentifier)
308- let evaluation = FocusCapabilityResolver . evaluate ( candidate. resolverCandidate)
309-
310- if evaluation. hasFullCapabilities {
311- return FocusCandidateResolution (
312- resolvedCandidate: candidate,
313- diagnosticCandidate: candidate,
314- resolution: FocusCapabilityResolution (
315- selectedEvaluation: evaluation,
316- inspectedCandidateCount: inspectedCount
317- )
336+ func winner( in elements: [ AXUIElement ] ) -> FocusCandidateResolution ? {
337+ for element in elements {
338+ inspectedCount += 1
339+ let candidate = candidateSnapshot (
340+ for: element,
341+ bundleIdentifier: bundleIdentifier,
342+ focusChangeSequence: focusChangeSequence,
343+ focusedReading: focusedReading
318344 )
345+ let evaluation = FocusCapabilityResolver . evaluate ( candidate. resolverCandidate)
346+
347+ if evaluation. hasFullCapabilities {
348+ return FocusCandidateResolution (
349+ resolvedCandidate: candidate,
350+ diagnosticCandidate: candidate,
351+ resolution: FocusCapabilityResolution (
352+ selectedEvaluation: evaluation,
353+ inspectedCandidateCount: inspectedCount
354+ )
355+ )
356+ }
357+
358+ if bestPartial == nil || evaluation. score > bestPartial!. evaluation. score {
359+ bestPartial = ( candidate, evaluation)
360+ }
319361 }
320362
321- if bestPartial == nil || evaluation. score > bestPartial!. evaluation. score {
322- bestPartial = ( candidate, evaluation)
363+ return nil
364+ }
365+
366+ var seen = Set < String > ( )
367+ let shallow = shallowCandidateElements ( around: focusedReading. element, seen: & seen)
368+ if let resolved = winner ( in: shallow. ordered) {
369+ return resolved
370+ }
371+
372+ if deepDescendants {
373+ var deepCandidates : [ AXUIElement ] = [ ]
374+ appendEditableDescendants ( of: [ focusedReading. element] + shallow. ancestors) { element in
375+ guard let element else {
376+ return
377+ }
378+ guard seen. insert ( AXHelper . elementIdentity ( for: element) ) . inserted else {
379+ return
380+ }
381+ deepCandidates. append ( element)
382+ }
383+ if let resolved = winner ( in: deepCandidates) {
384+ return resolved
323385 }
324386 }
325387
@@ -364,11 +426,15 @@ struct FocusSnapshotResolver {
364426 )
365427 }
366428
367- private func candidateElements(
368- around focusedElement: AXUIElement , deepDescendants: Bool = false
369- ) -> [ AXUIElement ] {
429+ /// Enumerates the cheap nearby candidates: the focused node, up to two ancestors, and their
430+ /// children. The Chromium descendant BFS is intentionally not part of this list — see
431+ /// `resolveCandidate` for the staging rationale (Chromium reports focus on a wrapper above the
432+ /// editable, AXWebArea → AXGroup → … → AXTextField, so the BFS exists as the fallback for the
433+ /// cases where this shallow neighborhood misses the real target).
434+ private func shallowCandidateElements(
435+ around focusedElement: AXUIElement , seen: inout Set < String >
436+ ) -> ( ordered: [ AXUIElement ] , ancestors: [ AXUIElement ] ) {
370437 var ordered : [ AXUIElement ] = [ ]
371- var seen = Set < String > ( )
372438
373439 func append( _ element: AXUIElement ? ) {
374440 guard let element else {
@@ -410,15 +476,7 @@ struct FocusSnapshotResolver {
410476 }
411477 }
412478
413- // Chromium reports focus on a wrapper above the editable (AXWebArea → AXGroup → … →
414- // AXTextField), so the shallow walk above can miss the real target. Search descendants for
415- // editable-looking nodes, bounded in depth and count and appending only likely editables
416- // (not every visited node) so per-tick candidateSnapshot cost stays in check.
417- if deepDescendants {
418- appendEditableDescendants ( of: [ focusedElement] + ancestors, append: append)
419- }
420-
421- return ordered
479+ return ( ordered, ancestors)
422480 }
423481
424482 /// Bounded BFS for editable-looking descendants, used only for Chromium/Electron. Traverses up
@@ -604,10 +662,24 @@ struct FocusSnapshotResolver {
604662 }
605663
606664 /// Extracts the AX properties Cotabby needs from one candidate element near the current focus.
607- private func candidateSnapshot( for element: AXUIElement , bundleIdentifier: String )
608- -> AXFocusCandidate {
609- let role = AXHelper . stringValue ( for: kAXRoleAttribute as CFString , on: element) ?? " Unknown "
610- let subrole = AXHelper . stringValue ( for: kAXSubroleAttribute as CFString , on: element)
665+ private func candidateSnapshot(
666+ for element: AXUIElement ,
667+ bundleIdentifier: String ,
668+ focusChangeSequence: UInt64 ,
669+ focusedReading: FocusedElementReading
670+ ) -> AXFocusCandidate {
671+ // `resolveSnapshot` already read the focused element's role pair for diagnostics, and the
672+ // focused element is the winning candidate in the common case; re-reading would repeat two
673+ // AX round trips on every poll tick. `CFEqual` is a local comparison, not an IPC.
674+ let role : String
675+ let subrole : String ?
676+ if CFEqual ( element, focusedReading. element) {
677+ role = focusedReading. role
678+ subrole = focusedReading. subrole
679+ } else {
680+ role = AXHelper . stringValue ( for: kAXRoleAttribute as CFString , on: element) ?? " Unknown "
681+ subrole = AXHelper . stringValue ( for: kAXSubroleAttribute as CFString , on: element)
682+ }
611683 let supportedAttributes = Set ( AXHelper . attributeNames ( on: element) )
612684 let supportedParameterizedAttributes = Set (
613685 AXHelper . parameterizedAttributeNames ( on: element) )
@@ -712,17 +784,33 @@ struct FocusSnapshotResolver {
712784 supportsFrame: supportedAttributes. contains ( " AXFrame " ) ,
713785 cocoaAnchorFrame: inputFrameRect,
714786 textValue: textValue,
715- textSelection: selection
787+ textSelection: selection,
788+ // The run-walk throttle slot is shared across calls, so it is restricted to the
789+ // focused element: that is the per-tick steady-state caller, and scoping prevents
790+ // one slot from serving run frames collected under a different root element.
791+ staticRunThrottle: CFEqual ( element, focusedReading. element)
792+ ? staticRunWalkThrottle
793+ : nil ,
794+ focusChangeSequence: focusChangeSequence
716795 )
717796 }
718797 let caretRect = caretResult? . rect
719798 let caretQuality = caretResult? . quality
720- let isSecure = isSecureElement ( element: element, role: role, subrole: subrole)
721799 // Recorded from the already-fetched attribute list (no extra AX call) so snapshot
722800 // assembly can classify the field as web-rendered without touching the element again.
723801 let vendsDOMAttributes = WebContentFieldDetector . vendsDOMAttributes ( supportedAttributes)
724802 let elementIdentifier = AXHelper . elementIdentifier (
725803 for: element, bundleIdentifier: bundleIdentifier)
804+ // Secure-ness is invariant for an element's lifetime, and the three marker probes behind
805+ // it (role description, title, description) are separate AX round trips otherwise paid on
806+ // every poll tick. Session scoping keeps recycled element identities from ever serving a
807+ // stale verdict to a different field.
808+ let isSecure = secureFieldVerdictCache. value (
809+ forKey: elementIdentifier,
810+ focusChangeSequence: focusChangeSequence
811+ ) {
812+ isSecureElement ( element: element, role: role, subrole: subrole)
813+ }
726814 let resolverCandidate = FocusCapabilityCandidate (
727815 elementIdentifier: elementIdentifier,
728816 role: role,
@@ -864,6 +952,14 @@ private struct FocusCandidateResolution {
864952 let resolution : FocusCapabilityResolution
865953}
866954
955+ /// The focused element together with its already-read role pair, so candidate snapshotting can
956+ /// reuse the reads `resolveSnapshot` performed for diagnostics instead of repeating the IPC.
957+ private struct FocusedElementReading {
958+ let element : AXUIElement
959+ let role : String
960+ let subrole : String ?
961+ }
962+
867963private struct AXTextSelection {
868964 let text : String
869965 let selection : NSRange
0 commit comments