Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cotabby/Models/SuggestionModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down
6 changes: 5 additions & 1 deletion Cotabby/Models/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 16 additions & 14 deletions Cotabby/Services/Focus/AXTextGeometryResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
31 changes: 20 additions & 11 deletions Cotabby/Services/Focus/FocusSnapshotResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))" }
Expand All @@ -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"
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion Cotabby/Services/Focus/FocusTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions CotabbyTests/AXTextGeometryResolverTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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"
Expand Down
Loading