diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 385002c..0c3583d 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -124,6 +124,7 @@ 6E49ADEB31D04DC77A47DEB0 /* FileLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */; }; 744B06C2488156B178675615 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; }; 76FD91607794883F8E121450 /* CaretGeometrySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */; }; + 78A8713A0E5B4C89E2D715BC /* FocusCapabilityFlickerGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */; }; 78FAE5DB691A1B71042B9D20 /* AboutPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */; }; 7B6A63F5DCC2C163CDFD2A5C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BC4F887528AE74AC0DD30314 /* Assets.xcassets */; }; 7C36DBA762E19C8C31676D44 /* MidWordContinuationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1274F897631B1B3A835D157F /* MidWordContinuationPolicyTests.swift */; }; @@ -181,6 +182,7 @@ BBE22CE4EF43247F8775B25D /* FocusPollBackoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09FADF683BE7B3558377FA76 /* FocusPollBackoff.swift */; }; BFCA7FAFDAEBF586AB615567 /* ClipboardRelevanceFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90B0D133AB77A2503FB08827 /* ClipboardRelevanceFilterTests.swift */; }; C0B833234748E82D3382631A /* emoji.json in Resources */ = {isa = PBXBuildFile; fileRef = C379D77029D6E88C8C1B9AF7 /* emoji.json */; }; + C0FE11D76BDF01A5470C554D /* FocusCapabilityFlickerGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */; }; C2C958D6E5F5FE1CCC414BCE /* SuggestionSubsystemContracts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEB16474A67CE1D210B944C9 /* SuggestionSubsystemContracts.swift */; }; C4C6734678797669055988E0 /* AppUpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD9573F3504CAE6891DF9B7D /* AppUpdateManager.swift */; }; C618C5595DA9C57C806A3E03 /* SettingsAttentionEvaluatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BC293F6125E2B14DCF05AD9 /* SettingsAttentionEvaluatorTests.swift */; }; @@ -269,6 +271,7 @@ 12DD19BCE610808F1E38702D /* PermissionOverlayTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionOverlayTrackerTests.swift; sourceTree = ""; }; 1441B2D89DAE6878DAD11F17 /* EmojiMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiMatcher.swift; sourceTree = ""; }; 18D990E515E1AE4F312F4E95 /* BundledRuntimeLocatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledRuntimeLocatorTests.swift; sourceTree = ""; }; + 1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityFlickerGateTests.swift; sourceTree = ""; }; 19BE12C28A4AB8A4A58C2FF7 /* SettingsPaneScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsPaneScaffold.swift; sourceTree = ""; }; 19DB9558F4D3AFB108D71649 /* SuggestionStateHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionStateHelperTests.swift; sourceTree = ""; }; 1A8414BEB7E34F57607E37FE /* EmojiVariantResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiVariantResolver.swift; sourceTree = ""; }; @@ -337,6 +340,7 @@ 62EDF1199CC5E18BD7651661 /* EmojiPickerPanelLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelLayout.swift; sourceTree = ""; }; 656F58E56FE9BC087B6F1D33 /* PermissionReminderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionReminderView.swift; sourceTree = ""; }; 67586807ACE8EB13C9014535 /* TickMarkSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TickMarkSlider.swift; sourceTree = ""; }; + 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityFlickerGate.swift; sourceTree = ""; }; 6B2D97BAA3618A7D0357AC44 /* SuggestionWorkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionWorkController.swift; sourceTree = ""; }; 6DC693E00430F46E41CB56E6 /* RequestID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestID.swift; sourceTree = ""; }; 70367FCC1E0F08EE3B8EB26F /* FocusCapabilityResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityResolver.swift; sourceTree = ""; }; @@ -709,6 +713,7 @@ 723E1EFA85D2E61B6C5F33E8 /* EmojiTriggerStateMachineTests.swift */, EE8BB19D8EC9A75CD3458A6B /* EmojiVariantResolverTests.swift */, 54BC85605541E913EE57B258 /* ExtendedContextTests.swift */, + 1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */, D4F6D5F94B238F7B4BE7C247 /* FocusCapabilityResolverTests.swift */, 273B4DC844F79B4BE2C8910F /* FocusPollBackoffTests.swift */, BA705EDFE1C41294F0E381F1 /* FocusSnapshotResolverSelectionTests.swift */, @@ -859,6 +864,7 @@ 312C7306D916963F519CE0D9 /* EmojiTriggerStateMachine.swift */, 1A8414BEB7E34F57607E37FE /* EmojiVariantResolver.swift */, CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */, + 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */, 70367FCC1E0F08EE3B8EB26F /* FocusCapabilityResolver.swift */, 09FADF683BE7B3558377FA76 /* FocusPollBackoff.swift */, FE7BF162A12703249726F20A /* FoundationModelPromptRenderer.swift */, @@ -1059,6 +1065,7 @@ 5DF31F465A3A1233260BD3A4 /* EngineAndModelPaneView.swift in Sources */, 5BE53CE921664582F593B7B0 /* FieldEdgeIconIndicatorView.swift in Sources */, 6E49ADEB31D04DC77A47DEB0 /* FileLogHandler.swift in Sources */, + C0FE11D76BDF01A5470C554D /* FocusCapabilityFlickerGate.swift in Sources */, CC98B842D10574C5206BEFA7 /* FocusCapabilityResolver.swift in Sources */, 924489CEE8171F7AD8579D71 /* FocusDebugOverlayController.swift in Sources */, 2402DC57AE2BCF6A686D30ED /* FocusModels.swift in Sources */, @@ -1201,6 +1208,7 @@ ED0843752B297D7E9DB2C468 /* EmojiTriggerStateMachineTests.swift in Sources */, C9B815652CED38966C53A5E8 /* EmojiVariantResolverTests.swift in Sources */, 63054CBDCA87560130BF5ADC /* ExtendedContextTests.swift in Sources */, + 78A8713A0E5B4C89E2D715BC /* FocusCapabilityFlickerGateTests.swift in Sources */, C71B594433F3B411CAE5DE7E /* FocusCapabilityResolverTests.swift in Sources */, A147C5EC3F2214A670F7556E /* FocusPollBackoffTests.swift in Sources */, 156E6AB3D24134EEC29FDB93 /* FocusSnapshotResolverSelectionTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift index 5e2a735..ccdea27 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift @@ -23,8 +23,23 @@ extension SuggestionCoordinator { } func handleFocusSnapshotChange(_ snapshot: FocusSnapshot) { + switch capabilityFlickerGate.evaluate(snapshot) { + case .apply: + break + case let .suppress(pendingBlockedReadCount: count): + // Single-poll AX flicker on the same element (see FocusCapabilityFlickerGate). Drop the + // event so the overlay does not bounce; log it so the suppression is observable. + let suppressedDetail = snapshot.capability.summary + CotabbyLogger.suggestion.trace( + // swiftlint:disable:next line_length + "Focus snapshot flicker suppressed: app=\(snapshot.applicationName) capability=\(snapshot.capability.shortLabel) detail=\(suppressedDetail) pendingBlockedReads=\(count)" + ) + return + } + + let changedDetail = snapshot.capability.summary CotabbyLogger.suggestion.trace( - "Focus snapshot changed: app=\(snapshot.applicationName) capability=\(snapshot.capability.shortLabel)" + "Focus snapshot changed: app=\(snapshot.applicationName) capability=\(snapshot.capability.shortLabel) detail=\(changedDetail)" ) // Start capturing visual context for a newly focused input even when predictions are // temporarily disabled by transient field states (e.g., "text is selected" or "secure diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/SuggestionCoordinator.swift index fcc8d33..c892a8e 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator.swift @@ -75,6 +75,11 @@ final class SuggestionCoordinator: ObservableObject { /// synchronous `refreshNow()` calls on the main actor. Bumping the token makes older chains /// no-op before they can perform another expensive AX read. var hostPublishPollGeneration: UInt64 = 0 + /// Suppresses single-poll `Supported → Blocked → Supported` flicker on the same focused element + /// so the overlay does not tear down and rebuild on every transient AX redraw. See + /// `FocusCapabilityFlickerGate` for the rationale and the reproduction (Apple Calendar event + /// editor). + var capabilityFlickerGate = FocusCapabilityFlickerGate() /// Correlation ID for the most recently built `SuggestionRequest`. Stamped onto every /// state-transition log line so all events tied to one suggestion (debounce → generating → /// ready → accepted/rejected) can be joined with a single `jq` filter on `request_id`. diff --git a/Cotabby/Support/FocusCapabilityFlickerGate.swift b/Cotabby/Support/FocusCapabilityFlickerGate.swift new file mode 100644 index 0000000..96596e9 --- /dev/null +++ b/Cotabby/Support/FocusCapabilityFlickerGate.swift @@ -0,0 +1,78 @@ +import Foundation + +/// File overview: +/// Suppresses transient `Supported → Blocked → Supported` capability flicker on the *same* focused +/// element so the suggestion overlay does not tear down and rebuild every time a host app momentarily +/// republishes its focused field. +/// +/// Background. `FocusCapabilityResolver` re-derives capability from live AX attributes on every poll +/// with no temporal smoothing. Some Catalyst-style fields (Apple Calendar's event editor is the +/// reproduction) briefly drop one of `textValue` / `selectionRange` / `caretBounds` while they +/// redraw, which collapses capability to Blocked for a single poll and bounces back to Supported on +/// the next. Without this gate every flicker drives `handleFocusSnapshotChange` to call +/// `disablePredictionsPreservingVisualContext` → `OverlayController.hide` → `panel.orderOut(nil)`, +/// which the user sees as the overlay opening and closing several times per second. +/// +/// The gate is intentionally tiny and pure: it tracks the most recently delivered Supported element +/// identity and a consecutive-Blocked counter, and tells the caller whether to apply the new +/// snapshot or keep treating the field as Supported for now. A persistent loss of capability (the +/// real "field went away" case) clears the gate after `requiredConsecutiveBlockedReads` so the +/// downgrade still propagates promptly — at the observed ~80–150 ms poll cadence that is roughly +/// 160–300 ms of suppression, well above the ~13 ms flicker pairs seen in the logs but short enough +/// that genuine focus loss is not perceptible. +struct FocusCapabilityFlickerGate { + /// How many consecutive Blocked snapshots on the same element must be observed before the gate + /// releases the downgrade. Two is enough to swallow the single-poll flicker without delaying + /// real focus-loss perceptibly at typical poll cadence. + static let requiredConsecutiveBlockedReads = 2 + + /// Outcome the caller acts on. + enum Decision: Equatable { + /// Apply this snapshot as-is. + case apply + /// Treat as a transient flicker: pretend the previous Supported snapshot is still current. + /// `pendingBlockedReadCount` is exposed for diagnostic logging only. + case suppress(pendingBlockedReadCount: Int) + } + + private var lastDeliveredSupportedElementID: String? + private var consecutiveBlockedReadCount: Int = 0 + + /// Feed every snapshot through here before letting it drive coordinator state. + mutating func evaluate(_ snapshot: FocusSnapshot) -> Decision { + switch snapshot.capability { + case .supported: + lastDeliveredSupportedElementID = snapshot.context?.elementIdentifier + consecutiveBlockedReadCount = 0 + return .apply + + case .blocked: + // Only debounce when we are still observing the same element that was just Supported. + // A different (or missing) element identifier is a genuine focus change and must + // propagate immediately. + guard let lastID = lastDeliveredSupportedElementID, + let currentID = snapshot.context?.elementIdentifier, + currentID == lastID + else { + lastDeliveredSupportedElementID = nil + consecutiveBlockedReadCount = 0 + return .apply + } + + consecutiveBlockedReadCount += 1 + if consecutiveBlockedReadCount >= Self.requiredConsecutiveBlockedReads { + lastDeliveredSupportedElementID = nil + consecutiveBlockedReadCount = 0 + return .apply + } + return .suppress(pendingBlockedReadCount: consecutiveBlockedReadCount) + + case .unsupported: + // Unsupported is "no focused text input at all" — never debounce; the user has left the + // field and the overlay must hide immediately. + lastDeliveredSupportedElementID = nil + consecutiveBlockedReadCount = 0 + return .apply + } + } +} diff --git a/CotabbyTests/FocusCapabilityFlickerGateTests.swift b/CotabbyTests/FocusCapabilityFlickerGateTests.swift new file mode 100644 index 0000000..18fab1d --- /dev/null +++ b/CotabbyTests/FocusCapabilityFlickerGateTests.swift @@ -0,0 +1,140 @@ +import XCTest +@testable import Cotabby + +final class FocusCapabilityFlickerGateTests: XCTestCase { + func testFirstSnapshotIsAlwaysApplied() { + var gate = FocusCapabilityFlickerGate() + XCTAssertEqual(gate.evaluate(supportedSnapshot(elementID: "field-A")), .apply) + } + + func testSingleBlockedFlickerOnSameElementIsSuppressed() { + var gate = FocusCapabilityFlickerGate() + _ = gate.evaluate(supportedSnapshot(elementID: "field-A")) + + let decision = gate.evaluate(blockedSnapshot(elementID: "field-A")) + + XCTAssertEqual(decision, .suppress(pendingBlockedReadCount: 1)) + } + + func testSupportedReturnAfterFlickerResetsTheGate() { + var gate = FocusCapabilityFlickerGate() + _ = gate.evaluate(supportedSnapshot(elementID: "field-A")) + _ = gate.evaluate(blockedSnapshot(elementID: "field-A")) + + XCTAssertEqual(gate.evaluate(supportedSnapshot(elementID: "field-A")), .apply) + + // After the reset, a fresh flicker is suppressed again rather than counted on top of the + // previous run. + XCTAssertEqual( + gate.evaluate(blockedSnapshot(elementID: "field-A")), + .suppress(pendingBlockedReadCount: 1) + ) + } + + func testTwoConsecutiveBlockedReadsReleaseTheDowngrade() { + var gate = FocusCapabilityFlickerGate() + _ = gate.evaluate(supportedSnapshot(elementID: "field-A")) + + XCTAssertEqual( + gate.evaluate(blockedSnapshot(elementID: "field-A")), + .suppress(pendingBlockedReadCount: 1) + ) + XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-A")), .apply) + } + + func testBlockedOnDifferentElementBypassesSuppression() { + var gate = FocusCapabilityFlickerGate() + _ = gate.evaluate(supportedSnapshot(elementID: "field-A")) + + XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-B")), .apply) + } + + func testBlockedWithoutAnyPriorSupportedIsApplied() { + var gate = FocusCapabilityFlickerGate() + XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-A")), .apply) + } + + func testUnsupportedIsNeverSuppressed() { + var gate = FocusCapabilityFlickerGate() + _ = gate.evaluate(supportedSnapshot(elementID: "field-A")) + + XCTAssertEqual(gate.evaluate(unsupportedSnapshot()), .apply) + } + + func testUnsupportedClearsPendingFlickerState() { + var gate = FocusCapabilityFlickerGate() + _ = gate.evaluate(supportedSnapshot(elementID: "field-A")) + _ = gate.evaluate(blockedSnapshot(elementID: "field-A")) + _ = gate.evaluate(unsupportedSnapshot()) + + // Without the clear, the next Blocked would resume the previous counter and downgrade + // immediately. The Unsupported observation must reset everything. + XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-A")), .apply) + } + + func testSupportedWithoutContextDoesNotArmTheGate() { + var gate = FocusCapabilityFlickerGate() + + // A Supported snapshot with no context (rare but possible — e.g. capability inferred from + // app identity before AX details settle) cannot be used as a reference for "same element" + // checks, so the next Blocked must propagate immediately rather than be silently + // suppressed. + let supportedWithoutContext = FocusSnapshot( + applicationName: "Calendar", + bundleIdentifier: "com.apple.iCal", + capability: .supported, + context: nil, + inspection: nil + ) + XCTAssertEqual(gate.evaluate(supportedWithoutContext), .apply) + XCTAssertEqual(gate.evaluate(blockedSnapshot(elementID: "field-A")), .apply) + } + + func testBlockedWithMissingContextIsAppliedEvenAfterSupported() { + var gate = FocusCapabilityFlickerGate() + _ = gate.evaluate(supportedSnapshot(elementID: "field-A")) + + // Loss of context on the snapshot means we can no longer prove "same element", so the + // gate has to defer to the downstream evaluator instead of holding the field as Supported. + let blockedWithoutContext = FocusSnapshot( + applicationName: "Calendar", + bundleIdentifier: "com.apple.iCal", + capability: .blocked("Text is currently selected."), + context: nil, + inspection: nil + ) + XCTAssertEqual(gate.evaluate(blockedWithoutContext), .apply) + } + + // MARK: - Helpers + + private func supportedSnapshot(elementID: String) -> FocusSnapshot { + FocusSnapshot( + applicationName: "Calendar", + bundleIdentifier: "com.apple.iCal", + capability: .supported, + context: CotabbyTestFixtures.focusedInputSnapshot(elementIdentifier: elementID), + inspection: nil + ) + } + + private func blockedSnapshot(elementID: String) -> FocusSnapshot { + FocusSnapshot( + applicationName: "Calendar", + bundleIdentifier: "com.apple.iCal", + capability: .blocked("Text is currently selected."), + context: CotabbyTestFixtures.focusedInputSnapshot(elementIdentifier: elementID), + inspection: nil + ) + } + + private func unsupportedSnapshot() -> FocusSnapshot { + FocusSnapshot( + applicationName: "Finder", + bundleIdentifier: "com.apple.finder", + capability: .unsupported("No focused text input"), + context: nil, + inspection: nil + ) + } +}