diff --git a/Cotabby/Models/SuggestionModels.swift b/Cotabby/Models/SuggestionModels.swift index 3ae951a..483b9f8 100644 --- a/Cotabby/Models/SuggestionModels.swift +++ b/Cotabby/Models/SuggestionModels.swift @@ -109,7 +109,7 @@ struct SuggestionConfiguration: Equatable, Sendable { // Seed the profile settings with lightweight defaults on first launch. defaultUserName: "Jacob", defaultWordCountPreset: .twelveToTwenty, - focusPollIntervalMilliseconds: 50 + focusPollIntervalMilliseconds: 80 ) } diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index a9d4385..8a3ef37 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -119,7 +119,11 @@ final class SuggestionSettingsModel: ObservableObject { let resolvedFocusPollIntervalMilliseconds: Int = { let raw = userDefaults.object(forKey: Self.focusPollIntervalMillisecondsDefaultsKey) as? Int ?? configuration.focusPollIntervalMilliseconds - return max(10, min(500, raw)) + // Existing installs may have the old 50ms first-launch default persisted. Floor at the + // shipped default so the hotfix bump reaches them — the stepper is hidden from the UI, + // so the persisted value is always the previous default, never a user-chosen override. + let floored = max(raw, configuration.focusPollIntervalMilliseconds) + return max(10, min(500, floored)) }() let resolvedMultiLineEnabled = userDefaults.object(forKey: Self.multiLineEnabledDefaultsKey) as? Bool ?? false diff --git a/Cotabby/Services/Focus/AXTextGeometryResolver.swift b/Cotabby/Services/Focus/AXTextGeometryResolver.swift index b94252d..0e63a9d 100644 --- a/Cotabby/Services/Focus/AXTextGeometryResolver.swift +++ b/Cotabby/Services/Focus/AXTextGeometryResolver.swift @@ -50,22 +50,24 @@ struct AXTextGeometryResolver { func resolveCaretRect( for element: AXUIElement, selection: NSRange, + supportsBoundsForRange: Bool, supportsFrame: Bool, cocoaAnchorFrame: CGRect?, textValue: String? = nil ) -> CaretGeometryResult? { // Branch 1: Zero-length BoundsForRange at the caret position — ideal case. - // We try this unconditionally because many apps support BoundsForRange without - // advertising it in parameterizedAttributeNames. Without the old gate, though, some - // AX nodes (deep Chrome leaves, ancestors with inherited selections) respond non-nil - // with rects that belong to an unrelated range. Anchor validation rejects those so we - // fall through to TextMarker / child runs / AXFrame instead of locking in garbage as - // `.exact` and overriding the primary candidate via the deep-tree search. - if let rect = AXHelper.parameterizedRectValue( - for: kAXBoundsForRangeParameterizedAttribute as CFString, - range: NSRange(location: selection.location, length: 0), - on: element - ), !rect.isEmpty { + // Gated on `supportsBoundsForRange` because the API is a synchronous cross-process + // call into the focused app's AX implementation. In Chrome that's a round-trip into + // the renderer, and the deep-tree walker can touch many leaves per focus poll; calling + // BoundsForRange on nodes that don't advertise support stalled the main thread badly + // enough to freeze typing. The `rectIsNearAnchor` validator stays as a correctness + // guard for supporters that return rects belonging to an unrelated range. + if supportsBoundsForRange, + let rect = AXHelper.parameterizedRectValue( + for: kAXBoundsForRangeParameterizedAttribute as CFString, + range: NSRange(location: selection.location, length: 0), + on: element + ), !rect.isEmpty { let cocoaRect = AXHelper.validatedCocoaTextRect( fromAccessibilityRect: rect, anchorFrame: cocoaAnchorFrame @@ -93,9 +95,9 @@ struct AXTextGeometryResolver { } // Branch 2: BoundsForRange on the character before the caret, then shift to its trailing edge. - // Same anchor validation as Branch 1 — the optimistic call can return rects for ranges - // that don't belong to this element. - if selection.location > 0, + // Same gate and anchor validation as Branch 1. + if supportsBoundsForRange, + selection.location > 0, let rect = AXHelper.parameterizedRectValue( for: kAXBoundsForRangeParameterizedAttribute as CFString, range: NSRange(location: selection.location - 1, length: 1), diff --git a/Cotabby/Services/Focus/FocusSnapshotResolver.swift b/Cotabby/Services/Focus/FocusSnapshotResolver.swift index f222994..c7e6153 100644 --- a/Cotabby/Services/Focus/FocusSnapshotResolver.swift +++ b/Cotabby/Services/Focus/FocusSnapshotResolver.swift @@ -361,6 +361,7 @@ struct FocusSnapshotResolver { if let range = AXHelper.rangeValue( for: kAXSelectedTextRangeAttribute as CFString, on: element ), range.length == 0 { + let paramAttrs = Set(AXHelper.parameterizedAttributeNames(on: element)) let attrs = Set(AXHelper.attributeNames(on: element)) let textValue = attrs.contains(kAXValueAttribute as String) @@ -369,6 +370,9 @@ struct FocusSnapshotResolver { let result = geometryResolver.resolveCaretRect( for: element, selection: range, + supportsBoundsForRange: paramAttrs.contains( + kAXBoundsForRangeParameterizedAttribute as String + ), supportsFrame: attrs.contains("AXFrame"), cocoaAnchorFrame: cocoaAnchorFrame, textValue: textValue @@ -430,6 +434,8 @@ struct FocusSnapshotResolver { let role = AXHelper.stringValue(for: kAXRoleAttribute as CFString, on: element) ?? "Unknown" let subrole = AXHelper.stringValue(for: kAXSubroleAttribute as CFString, on: element) let supportedAttributes = Set(AXHelper.attributeNames(on: element)) + let supportedParameterizedAttributes = Set( + AXHelper.parameterizedAttributeNames(on: element)) let explicitEditableFlag = supportedAttributes.contains("AXEditable") ? AXHelper.boolValue(for: "AXEditable" as CFString, on: element) @@ -478,6 +484,8 @@ struct FocusSnapshotResolver { geometryResolver.resolveCaretRect( for: element, selection: $0, + supportsBoundsForRange: supportedParameterizedAttributes.contains( + kAXBoundsForRangeParameterizedAttribute as String), supportsFrame: supportedAttributes.contains("AXFrame"), cocoaAnchorFrame: inputFrameRect, textValue: textValue @@ -582,6 +590,7 @@ struct FocusSnapshotResolver { let role = AXHelper.stringValue(for: kAXRoleAttribute as CFString, on: element) ?? "?" let subrole = AXHelper.stringValue(for: kAXSubroleAttribute as CFString, on: element) let attributes = Set(AXHelper.attributeNames(on: element)) + let parameterizedAttributes = Set(AXHelper.parameterizedAttributeNames(on: element)) var summary = "\(indent)\(role)" if let subrole { summary += " (\(subrole))" } @@ -603,17 +612,17 @@ struct FocusSnapshotResolver { if let range = AXHelper.rangeValue(for: kAXSelectedTextRangeAttribute as CFString, on: element) { summary += "\(indent) selection: loc=\(range.location) len=\(range.length)\n" - // Try BoundsForRange unconditionally so the dump reflects what the production resolver - // actually sees on Electron/WebKit elements that don't advertise the attribute. - let boundsRect = AXHelper.parameterizedRectValue( - for: kAXBoundsForRangeParameterizedAttribute as CFString, - range: NSRange(location: range.location, length: 0), - on: element - ) - if let boundsRect, !boundsRect.isEmpty { - summary += "\(indent) BoundsForRange(loc,0): \(fmt(boundsRect))\n" - } else { - summary += "\(indent) BoundsForRange(loc,0): FAILED\n" + if parameterizedAttributes.contains(kAXBoundsForRangeParameterizedAttribute as String) { + let boundsRect = AXHelper.parameterizedRectValue( + for: kAXBoundsForRangeParameterizedAttribute as CFString, + range: NSRange(location: range.location, length: 0), + on: element + ) + if let boundsRect, !boundsRect.isEmpty { + summary += "\(indent) BoundsForRange(loc,0): \(fmt(boundsRect))\n" + } else { + summary += "\(indent) BoundsForRange(loc,0): FAILED\n" + } } } diff --git a/Cotabby/Services/Focus/FocusTracker.swift b/Cotabby/Services/Focus/FocusTracker.swift index cf74083..ee66469 100644 --- a/Cotabby/Services/Focus/FocusTracker.swift +++ b/Cotabby/Services/Focus/FocusTracker.swift @@ -31,7 +31,7 @@ final class FocusTracker { private var lastFocusedInputSignature: FocusedInputPollingSignature? init( - pollInterval: TimeInterval = 0.05, + pollInterval: TimeInterval = 0.08, permissionProvider: @escaping @MainActor () -> Bool, ignoredBundleIdentifier: String?, snapshotResolver: FocusSnapshotResolver? = nil diff --git a/CotabbyTests/AXTextGeometryResolverTests.swift b/CotabbyTests/AXTextGeometryResolverTests.swift index 0a95861..6fd8458 100644 --- a/CotabbyTests/AXTextGeometryResolverTests.swift +++ b/CotabbyTests/AXTextGeometryResolverTests.swift @@ -6,9 +6,9 @@ import XCTest /// Tests for `AXTextGeometryResolver` caret resolution branch ordering. /// /// These tests use a real `NSTextField` hosted in the test process to exercise the AX geometry -/// pipeline end-to-end. Native AppKit text fields reliably support `AXBoundsForRange`, so they -/// validate that the optimistic BoundsForRange path produces `.exact` quality without requiring -/// the element to advertise the attribute in `parameterizedAttributeNames`. +/// pipeline end-to-end. Native AppKit text fields reliably support `AXBoundsForRange` and +/// advertise it via `parameterizedAttributeNames`, so the resolver's Branch 1/2 are reachable +/// when callers pass `supportsBoundsForRange: true`. @MainActor final class AXTextGeometryResolverTests: XCTestCase { private let resolver = AXTextGeometryResolver() @@ -50,6 +50,7 @@ final class AXTextGeometryResolverTests: XCTestCase { let resolved = resolver.resolveCaretRect( for: focusedElement, selection: NSRange(location: 5, length: 0), + supportsBoundsForRange: true, supportsFrame: true, cocoaAnchorFrame: nil ) @@ -83,6 +84,7 @@ final class AXTextGeometryResolverTests: XCTestCase { let result = resolver.resolveCaretRect( for: focusedElement, selection: NSRange(location: 0, length: 0), + supportsBoundsForRange: true, supportsFrame: true, cocoaAnchorFrame: nil ) @@ -110,6 +112,7 @@ final class AXTextGeometryResolverTests: XCTestCase { let result = resolver.resolveCaretRect( for: focusedElement, selection: NSRange(location: 3, length: 0), + supportsBoundsForRange: true, supportsFrame: true, cocoaAnchorFrame: nil, textValue: "Fallback test"