-
-
Notifications
You must be signed in to change notification settings - Fork 29
Suppress AX capability flicker on the same focused element #494
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| ) | ||
| } | ||
| } | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.