diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index ddd90ac8..9010f722 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -387,6 +387,7 @@ 62B45766E8A2827E31AFD356 /* ScreenFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B49898B64D4DB50736CBC46 /* ScreenFrameReader.swift */; }; 62DBCF429B7F464A6B467725 /* OnboardingFeatureShowcase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 926B332E7B4CFEE42C4CAA75 /* OnboardingFeatureShowcase.swift */; }; 632C9A837EC1949B098A0BD9 /* SettingsNavigationModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2CAE5772E4C748960219DA0 /* SettingsNavigationModelTests.swift */; }; + 632F4F9A37C801C8ED15D703 /* DoubleTapAcceptanceState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7380A4057E7BC21F0977A07 /* DoubleTapAcceptanceState.swift */; }; 635026FA5A2CDD7CB29AC20F /* LlamaSuggestionEnginePrewarmTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BCF5B9CC0B74A171519B6D8 /* LlamaSuggestionEnginePrewarmTests.swift */; }; 644EEF959D07D54CC779BBF6 /* SettingsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3350EDE01ED5125520C79D53 /* SettingsCoordinator.swift */; }; 6471AEB3C75714538D78DBA3 /* SuggestionSettingsModelDisabledAppsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC07B7CE715C69A63E6123DD /* SuggestionSettingsModelDisabledAppsTests.swift */; }; @@ -411,6 +412,7 @@ 6952E5B030740443C684F263 /* SettingsSearchRanker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57B29DAC600626783649475A /* SettingsSearchRanker.swift */; }; 6955C3A4D7AB3EEF7FA7C469 /* InputSuppressionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D1F9CEBAB0F330F8E7B61D8 /* InputSuppressionController.swift */; }; 696514ADA5A7D085C333BE35 /* TextLayoutCaretEstimatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 961A47BBFDBF7C233BC4DDC4 /* TextLayoutCaretEstimatorTests.swift */; }; + 69651DED6B023F32196500CF /* DoubleTapAcceptanceStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F86E2E28EBDFA1FEBE6CC90A /* DoubleTapAcceptanceStateTests.swift */; }; 698E568874BCAD20BBE82875 /* SuggestionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E76C76AEE756F43787EEECE /* SuggestionCoordinator.swift */; }; 6A36F49DE520844FC971F704 /* SuggestionOverlayStabilityGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D10022BEFABAD14D16EBF2 /* SuggestionOverlayStabilityGateTests.swift */; }; 6A3C43437DEECBCF935C1F12 /* ClipboardContextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4B70455CA75A08FAD0F7409 /* ClipboardContextProvider.swift */; }; @@ -686,6 +688,7 @@ B1E93ED4D7C0B9BB77F95ADC /* CompositionInputModeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9316289DD11A72D3EAC0A929 /* CompositionInputModeClassifier.swift */; }; B2A4C1D06FE4317F32BD8E39 /* InsertionStrategySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D239BFA9C9061C04956C591 /* InsertionStrategySelector.swift */; }; B2A6C1474B9F3A6E26F488C9 /* UnitConversionEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6B0048AAD1B2A4EA09DCEE0 /* UnitConversionEvaluator.swift */; }; + B327BA8266E575FFF30AE288 /* DoubleTapAcceptanceState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7380A4057E7BC21F0977A07 /* DoubleTapAcceptanceState.swift */; }; B3706BDA93A1D8790CE8CC4A /* EmojiSynonymCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4DB988251D2B907F316A3B8 /* EmojiSynonymCatalogTests.swift */; }; B393275E40EA9EB901E20820 /* AppSurfaceClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17A45844F33B55A3A58E9C71 /* AppSurfaceClassifier.swift */; }; B3E9C077CF064F2DEC3E22B8 /* ModelAndPresentationValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BA1D28712B262ABD96F8399 /* ModelAndPresentationValueTests.swift */; }; @@ -898,6 +901,7 @@ E9B7E353B233F842AC859542 /* MenuBarRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E267F3BB7FC8DEAEB5E841B /* MenuBarRecoveryPolicy.swift */; }; E9D65F5D5452B595886F43DC /* GhostWrapBandPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B753DA96CA6BC168E675E61E /* GhostWrapBandPolicy.swift */; }; E9E4CC657771DF9F4C56183C /* VisualContextCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A854CAFB1F557BC4CAED8819 /* VisualContextCoordinator.swift */; }; + EA16F79329E37D921F0BB542 /* SuggestionSettingsModelDoubleTapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC30D79131D562C5CD7FA639 /* SuggestionSettingsModelDoubleTapTests.swift */; }; EA353CCECBFB4D297C865447 /* CurrentWordExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1AAC6367483952AB7D59A62 /* CurrentWordExtractor.swift */; }; EA6E9DD40B45A48FCBED4BCB /* SuggestionCoordinator+TypingPrediction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57D3DEF846A089D523BB8BC0 /* SuggestionCoordinator+TypingPrediction.swift */; }; EA79F5362D5CDBC3B4F3701A /* HostMarkedTextPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9CFD041A13DACE5612DC991 /* HostMarkedTextPolicy.swift */; }; @@ -977,6 +981,7 @@ FECBB40B75788B449719170B /* DeepGeometryWalkThrottle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CCBD29CF7B0BA5A9CFDEEA3 /* DeepGeometryWalkThrottle.swift */; }; FECD5A1F9317317983B49D34 /* SuggestionDismissalMemory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17F3333CFD174AB34060A0BD /* SuggestionDismissalMemory.swift */; }; FEF2CF888D8709D1FB0D2B20 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 6F27073D2818C0218C3F4370 /* Logging */; }; + FEF7B8713C48B4B9FB2DB399 /* SuggestionCoordinatorDoubleTapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E6587C61CBD8D64E852EBF /* SuggestionCoordinatorDoubleTapTests.swift */; }; FF46903861BA67AED8C24EF8 /* HuggingFaceModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C736C539D4C97B5664E498BE /* HuggingFaceModels.swift */; }; FF4C26309DCF9532C9283284 /* EmojiRecentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF02469BF40CC6BAF2675E37 /* EmojiRecentsTests.swift */; }; FF65453AC3797BBF2F39EBBB /* SuggestionInteractionStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45207F82F22BAE1E89286E4B /* SuggestionInteractionStateTests.swift */; }; @@ -1234,6 +1239,7 @@ 655F56C230F45CAC13EEE59C /* SuggestionTextColorCodecTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextColorCodecTests.swift; sourceTree = ""; }; 6586D6D735F287F43D42520A /* DeviceInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfo.swift; sourceTree = ""; }; 65AF0FF1CE97A0761ADAFEB8 /* InlineCommandCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineCommandCoordinatorTests.swift; sourceTree = ""; }; + 65E6587C61CBD8D64E852EBF /* SuggestionCoordinatorDoubleTapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorDoubleTapTests.swift; sourceTree = ""; }; 65E97C4BFAF5EAF6792170AC /* FocusSnapshotExternalApplicationIdentityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSnapshotExternalApplicationIdentityTests.swift; sourceTree = ""; }; 665A55CA2E7975CBFC63B4E2 /* InkBaselineAnalyzer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InkBaselineAnalyzer.swift; sourceTree = ""; }; 66B5FF2D1DCC846AABC134CF /* MenuBarRecoveryPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarRecoveryPolicyTests.swift; sourceTree = ""; }; @@ -1395,6 +1401,7 @@ A5B09274BE9500E3C41C18B2 /* HostZoomLadder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostZoomLadder.swift; sourceTree = ""; }; A66389B78743ABACE7CD4D82 /* SuggestionWorkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionWorkController.swift; sourceTree = ""; }; A6B0048AAD1B2A4EA09DCEE0 /* UnitConversionEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitConversionEvaluator.swift; sourceTree = ""; }; + A7380A4057E7BC21F0977A07 /* DoubleTapAcceptanceState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleTapAcceptanceState.swift; sourceTree = ""; }; A73D95F2BF5CA59705653931 /* PromptSectionBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptSectionBudgetTests.swift; sourceTree = ""; }; A7FBE9101AF744A4D205B32D /* EmojiSynonymCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiSynonymCatalog.swift; sourceTree = ""; }; A854CAFB1F557BC4CAED8819 /* VisualContextCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualContextCoordinator.swift; sourceTree = ""; }; @@ -1494,6 +1501,7 @@ CBD829565558CB1EA8CA69D5 /* BrowserDomainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDomainTests.swift; sourceTree = ""; }; CBDBD55F205C609303913423 /* SpellingDictionaryResourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpellingDictionaryResourceTests.swift; sourceTree = ""; }; CC1EDFB535AAA2EE0D67828A /* CotabbyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyApp.swift; sourceTree = ""; }; + CC30D79131D562C5CD7FA639 /* SuggestionSettingsModelDoubleTapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModelDoubleTapTests.swift; sourceTree = ""; }; CC5CD24D2FF35FD36B8D78DE /* SuggestionQualityMetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionQualityMetricsStoreTests.swift; sourceTree = ""; }; CD1FC547BE2E96D2ABB55DF4 /* LlamaSuggestionEngineStreamingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaSuggestionEngineStreamingTests.swift; sourceTree = ""; }; CE8B99181BF74C1F1DC5F6A7 /* HostTextMetricsCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostTextMetricsCache.swift; sourceTree = ""; }; @@ -1602,6 +1610,7 @@ F7014E6E969FD4579028BAC4 /* TypingCadence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypingCadence.swift; sourceTree = ""; }; F7F3F983901FC3648076B23D /* MacroReferenceSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroReferenceSheet.swift; sourceTree = ""; }; F7F727A325ECE687B32CDC38 /* Aria2ProvisionerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Aria2ProvisionerTests.swift; sourceTree = ""; }; + F86E2E28EBDFA1FEBE6CC90A /* DoubleTapAcceptanceStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DoubleTapAcceptanceStateTests.swift; sourceTree = ""; }; F952E54538EF82D1B1E672D8 /* WelcomePermissionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePermissionStepView.swift; sourceTree = ""; }; FA4364402391C8A8D9B3FCEC /* PhrasePredictionScoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhrasePredictionScoring.swift; sourceTree = ""; }; FA97ED2A10499771F9C32B64 /* FoundationModelAvailabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelAvailabilityService.swift; sourceTree = ""; }; @@ -1744,6 +1753,7 @@ 022734DBBC384802C7CDB97D /* SuggestionConversationIsolationTests.swift */, 4E1EBD88A434C325CC6EFBDF /* SuggestionCoordinatorAcceptanceTests.swift */, 5830E18C10728B7AF1DB2CCF /* SuggestionCoordinatorContinuityTests.swift */, + 65E6587C61CBD8D64E852EBF /* SuggestionCoordinatorDoubleTapTests.swift */, 694D2DD5826C379BA4A6C775 /* SuggestionCoordinatorInputTests.swift */, 25C4859A89B59938DB905377 /* SuggestionCoordinatorLifecycleTests.swift */, EAA290A7B1BF37F3D125DC8F /* SuggestionCoordinatorPredictionTests.swift */, @@ -2391,6 +2401,7 @@ 6C207CE2D46ADA4661468CBA /* Acceptance */ = { isa = PBXGroup; children = ( + F86E2E28EBDFA1FEBE6CC90A /* DoubleTapAcceptanceStateTests.swift */, 35AA2C8F42B510F013D86C3C /* InsertedTextAdvanceTests.swift */, 67EA130AF8073D90CA89AAC6 /* InsertionSafetyGateTests.swift */, 12BB15F7F2E595129B190E89 /* InsertionStrategySelectorTests.swift */, @@ -3126,6 +3137,7 @@ BFDD0073B68616AB220BE0AE /* Acceptance */ = { isa = PBXGroup; children = ( + A7380A4057E7BC21F0977A07 /* DoubleTapAcceptanceState.swift */, 6C8C1A4EC935BB837432BAF2 /* InsertedTextAdvance.swift */, 8E89746E8CE7E9487337EE6F /* InsertionSafetyGate.swift */, 0D239BFA9C9061C04956C591 /* InsertionStrategySelector.swift */, @@ -3439,6 +3451,7 @@ A306C7B2B0790A0B98D8F353 /* PerDomainDisableSettingsTests.swift */, DA02598F555696782E180DA5 /* ShortcutConflictTests.swift */, 2E7C883990AE384602BA9AF6 /* SuggestionSettingsDomainTests.swift */, + CC30D79131D562C5CD7FA639 /* SuggestionSettingsModelDoubleTapTests.swift */, D253D06D941F60EDAF112625 /* SuggestionSettingsModelTests.swift */, ); path = Settings; @@ -3904,6 +3917,7 @@ 0A9F8963DABEDBFFD8D5A239 /* DeepGeometryWalkThrottle.swift in Sources */, 1C30344A181A9F6EBC4A9C8F /* DeviceInfo.swift in Sources */, F3FC8AEB8504F7D1BB1C6443 /* DisplayCoordinateConverter.swift in Sources */, + B327BA8266E575FFF30AE288 /* DoubleTapAcceptanceState.swift in Sources */, 936F87925A22DAC58525B60F /* DownloadFileRescuer.swift in Sources */, 6C4EFF98C9D606B3BE35D2A6 /* DownloadOutcomeClassifier.swift in Sources */, EDD9AE89E16AB1EF47D06269 /* DownloadableModelCatalogView.swift in Sources */, @@ -4231,6 +4245,7 @@ FECBB40B75788B449719170B /* DeepGeometryWalkThrottle.swift in Sources */, D3909BBB80781B0CE1982A8E /* DeviceInfo.swift in Sources */, FC4706CDB470537FE069FF22 /* DisplayCoordinateConverter.swift in Sources */, + 632F4F9A37C801C8ED15D703 /* DoubleTapAcceptanceState.swift in Sources */, F26DD9AEAFECAAAD13F3A0A0 /* DownloadFileRescuer.swift in Sources */, 2089CC6E33B24D5CD3DD1E57 /* DownloadOutcomeClassifier.swift in Sources */, 5B3FAD231ECFC7539C33FC1E /* DownloadableModelCatalogView.swift in Sources */, @@ -4542,6 +4557,7 @@ A6A24D462E6C5B8A4963145D /* DeepGeometryWalkThrottleTests.swift in Sources */, 4A460D26B773DC213B0E6471 /* DeviceInfoTests.swift in Sources */, 53BA549ABCA25E9F44DF6341 /* DisplayCoordinateConverterTests.swift in Sources */, + 69651DED6B023F32196500CF /* DoubleTapAcceptanceStateTests.swift in Sources */, E5022E7BF72591C0FEE80CD7 /* DownloadFileRescuerTests.swift in Sources */, C7A48F93691943EF2EEBD775 /* DownloadOutcomeClassifierTests.swift in Sources */, 311A4FDEFFEB67B337FEE47A /* EmojiCatalogMatcherTests.swift in Sources */, @@ -4687,6 +4703,7 @@ 764F157C87F3155FBE698A3A /* SuggestionConversationIsolationTests.swift in Sources */, BC0E4576CEA12EA3600D5A0A /* SuggestionCoordinatorAcceptanceTests.swift in Sources */, B6C2570DB8D17C8626D50F76 /* SuggestionCoordinatorContinuityTests.swift in Sources */, + FEF7B8713C48B4B9FB2DB399 /* SuggestionCoordinatorDoubleTapTests.swift in Sources */, 1DBFE5862FEFD38793600B01 /* SuggestionCoordinatorInputTests.swift in Sources */, 03175AAC8AA2789681B18DFB /* SuggestionCoordinatorLifecycleTests.swift in Sources */, 0A87210653A6C00EA62DC005 /* SuggestionCoordinatorPredictionTests.swift in Sources */, @@ -4715,6 +4732,7 @@ B8B71602DFA0E6B7E61E2692 /* SuggestionSessionTypingTests.swift in Sources */, E83E989C5132F2A8C8FED8EB /* SuggestionSettingsDomainTests.swift in Sources */, 6471AEB3C75714538D78DBA3 /* SuggestionSettingsModelDisabledAppsTests.swift in Sources */, + EA16F79329E37D921F0BB542 /* SuggestionSettingsModelDoubleTapTests.swift in Sources */, 2D83CECB0FF076323226449D /* SuggestionSettingsModelTests.swift in Sources */, 19449988F10AD289E2682F04 /* SuggestionSettingsStoreTests.swift in Sources */, F9ED18D3A56FDAAA38C9B0F9 /* SuggestionStaleAcceptanceEchoTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift index c35159cd..e1b03218 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Acceptance.swift @@ -19,6 +19,39 @@ extension SuggestionCoordinator { acceptSuggestion(fullText: true, keyName: "full-accept") } + /// Entry point for a real press of the Accept Word key. + /// + /// With double-tap enabled, the first press still accepts one word immediately, and a second + /// press within `DoubleTapAcceptanceState.window` on the same suggestion accepts what remains. + /// Promoting the second press instead of delaying the first keeps single-word acceptance as fast + /// as before; the pair still commits the whole suggestion. The queued post-exhaustion accept + /// calls `acceptCurrentSuggestion` directly, so a Tab buffered during regeneration never turns + /// into an accept-everything of a continuation the user has not seen yet. + func acceptForWordAcceptKeyPress() -> Bool { + guard settingsSnapshot.doubleTapAcceptsEntireSuggestion else { + doubleTapAcceptanceState.reset() + return acceptCurrentSuggestion() + } + + let now = ProcessInfo.processInfo.systemUptime + if let session = interactionState.activeSession, !session.kind.isCorrection, + doubleTapAcceptanceState.consumeDoubleTap(of: .init(session: session), at: now) { + return acceptSuggestion(fullText: true, keyName: "double-tap") + } + + let accepted = acceptCurrentSuggestion() + // Arm only when this press left a continuation with text still to accept. An exhausted + // suggestion hands Tab to the post-exhaustion window instead, and a correction commits as + // a unit, so neither has a "rest" for a second press to take. + if accepted, let advanced = interactionState.activeSession, !advanced.kind.isCorrection, + !advanced.isExhausted { + doubleTapAcceptanceState.recordWordAccept(of: .init(session: advanced), at: now) + } else { + doubleTapAcceptanceState.reset() + } + return accepted + } + /// Shared acceptance path used by both word-by-word and full acceptance. private func acceptSuggestion( fullText: Bool, diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift index c0730e75..3ccff678 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Input.swift @@ -286,8 +286,14 @@ extension SuggestionCoordinator { completion: session.remainingText, at: ProcessInfo.processInfo.systemUptime) } + // Anything between two Accept Word presses (typing, navigation, the full-accept key) means + // they are not one double tap, even if both land inside the timing window. + if event.kind != .acceptance { + doubleTapAcceptanceState.reset() + } + if event.kind == .acceptance { - return acceptCurrentSuggestion() + return acceptForWordAcceptKeyPress() } if event.kind == .fullAcceptance { diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift index 227073e9..41724439 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator.swift @@ -174,6 +174,10 @@ final class SuggestionCoordinator: ObservableObject { /// coordinator continues to own the timer and input-monitor effects around these transitions. var postExhaustionAcceptanceState = PostExhaustionAcceptanceState() + /// Pure state for recognizing a quick second press of the Accept Word key. Only real key presses + /// feed it; the queued post-exhaustion accept stays a plain one-word accept. + var doubleTapAcceptanceState = DoubleTapAcceptanceState() + init( permissionManager: any SuggestionPermissionProviding, lowPowerModeProvider: any SuggestionLowPowerModeProviding, diff --git a/Cotabby/Models/Settings/SuggestionSettingsData.swift b/Cotabby/Models/Settings/SuggestionSettingsData.swift index d8241b99..067eef5b 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsData.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsData.swift @@ -111,6 +111,10 @@ struct SuggestionShortcutSettings: Equatable { var fullAcceptance: SuggestionShortcutBindingSettings var globalToggle: SuggestionShortcutBindingSettings var perAppOverrides: [PerAppShortcutOverride] + /// When true, pressing the Accept Word key twice in quick succession accepts the whole + /// suggestion. It modifies the existing word-accept binding rather than adding a new binding, so + /// it applies to per-app accept overrides as well. + var doubleTapAcceptsEntireSuggestion: Bool } /// Pure domain representation of every durable suggestion preference. @@ -469,4 +473,9 @@ extension SuggestionSettingsData { get { shortcuts.perAppOverrides } set { shortcuts.perAppOverrides = newValue } } + + var doubleTapAcceptsEntireSuggestion: Bool { + get { shortcuts.doubleTapAcceptsEntireSuggestion } + set { shortcuts.doubleTapAcceptsEntireSuggestion = newValue } + } } diff --git a/Cotabby/Models/Settings/SuggestionSettingsModel.swift b/Cotabby/Models/Settings/SuggestionSettingsModel.swift index 61644320..084c0fbd 100644 --- a/Cotabby/Models/Settings/SuggestionSettingsModel.swift +++ b/Cotabby/Models/Settings/SuggestionSettingsModel.swift @@ -156,6 +156,10 @@ final class SuggestionSettingsModel: ObservableObject { /// Per-app accept/full-accept overrides. Published so the input monitor's event-time provider /// closures (via `ShortcutResolver`) and the Apps settings pane both observe the live list. @Published private(set) var perAppShortcutOverrides: [PerAppShortcutOverride] + /// Whether Accept Entire Suggestion is bound to a quick double press of the Accept Word key. + /// The Accept Entire Suggestion slot holds one shortcut, so while this is true the one-press + /// full-accept key is unbound; the setters below keep the two mutually exclusive. + @Published private(set) var doubleTapAcceptsEntireSuggestion: Bool @Published private(set) var acceptanceGranularity: AcceptanceGranularity @Published private(set) var isPowerBasedModelSwitchingEnabled: Bool @Published private(set) var batteryEngine: SuggestionEngineKind @@ -284,6 +288,7 @@ final class SuggestionSettingsModel: ObservableObject { globalToggleKeyModifiers = data.globalToggleKeyModifiers globalToggleKeyLabel = data.globalToggleKeyLabel perAppShortcutOverrides = data.perAppShortcutOverrides + doubleTapAcceptsEntireSuggestion = data.doubleTapAcceptsEntireSuggestion acceptanceGranularity = data.acceptanceGranularity isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled batteryEngine = data.batteryEngine @@ -367,6 +372,7 @@ final class SuggestionSettingsModel: ObservableObject { globalToggleKeyModifiers = data.globalToggleKeyModifiers globalToggleKeyLabel = data.globalToggleKeyLabel perAppShortcutOverrides = data.perAppShortcutOverrides + doubleTapAcceptsEntireSuggestion = data.doubleTapAcceptsEntireSuggestion acceptanceGranularity = data.acceptanceGranularity isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled batteryEngine = data.batteryEngine @@ -480,7 +486,8 @@ final class SuggestionSettingsModel: ObservableObject { modifiers: globalToggleKeyModifiers, label: globalToggleKeyLabel ), - perAppOverrides: perAppShortcutOverrides + perAppOverrides: perAppShortcutOverrides, + doubleTapAcceptsEntireSuggestion: doubleTapAcceptsEntireSuggestion ) ) } @@ -521,7 +528,8 @@ final class SuggestionSettingsModel: ObservableObject { suppressCompletionsOnTypo: settings.correction.suppressCompletionsOnTypo, offerTypoCorrections: settings.correction.offerTypoCorrections, enabledSpellingDictionaryCodes: settings.correction.enabledSpellingDictionaryCodes, - automaticallyFixTypos: settings.correction.automaticallyFixTypos + automaticallyFixTypos: settings.correction.automaticallyFixTypos, + doubleTapAcceptsEntireSuggestion: settings.shortcuts.doubleTapAcceptsEntireSuggestion ) } @@ -903,6 +911,60 @@ final class SuggestionSettingsModel: ObservableObject { ) } + private func setDoubleTapAcceptsEntireSuggestion(_ enabled: Bool) { + guard doubleTapAcceptsEntireSuggestion != enabled else { + return + } + doubleTapAcceptsEntireSuggestion = enabled + store.saveDoubleTapAcceptsEntireSuggestion(enabled) + } + + /// Binds Accept Entire Suggestion to a double press of the Accept Word key, replacing any + /// one-press key in that slot. Ignored while Accept Word is unbound, since there is no key to + /// press twice. + func setDoubleTapFullAcceptance() { + guard acceptanceKeyCode != Self.disabledKeyCode else { return } + setFullAcceptanceKey(keyCode: Self.disabledKeyCode, modifiers: [], label: Self.disabledKeyLabel) + setDoubleTapAcceptsEntireSuggestion(true) + } + + /// True when the double-tap binding can actually fire. It rides on the Accept Word key, so it + /// is inert while that key is unbound. + var isDoubleTapFullAcceptanceActive: Bool { + doubleTapAcceptsEntireSuggestion && acceptanceKeyCode != Self.disabledKeyCode + } + + /// The Accept Entire Suggestion shortcut as users should read it: the one-press key, or the + /// Accept Word key written twice ("Tab Tab"). + var fullAcceptanceDisplayLabel: String { + isDoubleTapFullAcceptanceActive ? "\(acceptanceKeyLabel) \(acceptanceKeyLabel)" : fullAcceptanceKeyLabel + } + + /// What accepts the whole suggestion in one app when it inherits the global shortcut. The + /// double tap is a double press of that app's own Accept Word key, so a per-app Accept Word + /// override changes it too, and disabling Accept Word there leaves no double tap at all. + func inheritedFullAcceptanceDisplayLabel(forBundleIdentifier bundleIdentifier: String?) -> String { + let fullAccept = resolvedFullAcceptBinding(forBundleIdentifier: bundleIdentifier) + guard doubleTapAcceptsEntireSuggestion, fullAccept.keyCode == Self.disabledKeyCode else { + return fullAccept.label + } + let accept = resolvedAcceptBinding(forBundleIdentifier: bundleIdentifier) + guard accept.keyCode != Self.disabledKeyCode else { return fullAccept.label } + return "\(accept.label) \(accept.label)" + } + + /// Whether any shortcut accepts the whole suggestion, so views can offer Clear. + var hasFullAcceptanceShortcut: Bool { + isDoubleTapFullAcceptanceActive || fullAcceptanceKeyCode != Self.disabledKeyCode + } + + /// Whether the slot still holds the factory one-press key, so views can hide Reset. + var isFullAcceptanceShortcutDefault: Bool { + !isDoubleTapFullAcceptanceActive + && fullAcceptanceKeyCode == Self.defaultFullAcceptanceKeyCode + && fullAcceptanceKeyModifiers.isEmpty + } + func setAutoAcceptTrailingPunctuation(_ enabled: Bool) { guard autoAcceptTrailingPunctuation != enabled else { return @@ -1328,10 +1390,18 @@ final class SuggestionSettingsModel: ObservableObject { } func clearAcceptanceKey() { + // The double-tap binding is a double press of this key, so it goes away with it rather than + // reappearing unexpectedly when a new Accept Word key is recorded later. + setDoubleTapAcceptsEntireSuggestion(false) setAcceptanceKey(keyCode: Self.disabledKeyCode, modifiers: [], label: Self.disabledKeyLabel) } func setFullAcceptanceKey(keyCode: CGKeyCode, modifiers: ShortcutModifierMask, label: String) { + // A real key takes the slot over from a double tap. Unbinding leaves the flag alone so + // `setDoubleTapFullAcceptance` can clear the key without undoing itself. + if keyCode != Self.disabledKeyCode { + setDoubleTapAcceptsEntireSuggestion(false) + } let normalizedModifiers = keyCode == Self.disabledKeyCode ? [] : modifiers guard fullAcceptanceKeyCode != keyCode || fullAcceptanceKeyModifiers != normalizedModifiers @@ -1353,6 +1423,7 @@ final class SuggestionSettingsModel: ObservableObject { } func clearFullAcceptanceKey() { + setDoubleTapAcceptsEntireSuggestion(false) setFullAcceptanceKey(keyCode: Self.disabledKeyCode, modifiers: [], label: Self.disabledKeyLabel) } @@ -1647,10 +1718,13 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { // top-level setting gets layered above via another `CombineLatest`. `extendedContext` joins // alongside `acceptanceGranularity` here for the same reason. The three custom-range fields // travel together as a single tuple so they only cost one slot in this outer layer. - let customRange = Publishers.CombineLatest3( + // The double-tap toggle rides in this slot's last free input; it is unrelated to the range + // but costs no extra layer of nesting. + let customRange = Publishers.CombineLatest4( $isUsingCustomWordCountRange, $customWordCountLowWords, - $customWordCountHighWords + $customWordCountHighWords, + $doubleTapAcceptsEntireSuggestion ) // The outer `CombineLatest4` is full, so these settings share its grouped publisher slot. return Publishers.CombineLatest4( @@ -1674,7 +1748,7 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { let (debounce, focusPoll, generationToggles, acceptToggles) = timing let (multiLine, suggestWithinWords, showFollowingWords) = generationToggles let (autoAcceptPunctuation, addSpaceAfterAccept, streamWhileGenerating, predictAhead) = acceptToggles - let (isCustomActive, customLow, customHigh) = customRangeTuple + let (isCustomActive, customLow, customHigh, doubleTapAcceptsEntireSuggestion) = customRangeTuple let (extendedContext, suggestInIntegratedTerminals, surfaceContextEnabled, lowPowerModeAutoDisableEnabled) = extendedContextTuple return SuggestionSettingsSnapshot( @@ -1708,7 +1782,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { suppressCompletionsOnTypo: suppressOnTypo, offerTypoCorrections: offerCorrections, enabledSpellingDictionaryCodes: enabledSpellingDictionaryCodes, - automaticallyFixTypos: automaticallyFixTypos + automaticallyFixTypos: automaticallyFixTypos, + doubleTapAcceptsEntireSuggestion: doubleTapAcceptsEntireSuggestion ) } .removeDuplicates() diff --git a/Cotabby/Models/Suggestion/SuggestionEngineModels.swift b/Cotabby/Models/Suggestion/SuggestionEngineModels.swift index 4ccfd538..df7299c4 100644 --- a/Cotabby/Models/Suggestion/SuggestionEngineModels.swift +++ b/Cotabby/Models/Suggestion/SuggestionEngineModels.swift @@ -186,6 +186,10 @@ struct SuggestionSettingsSnapshot: Equatable, Sendable { /// commits the misspelled word with Space. The word boundary prevents pauses in unfinished words /// from triggering destructive edits. let automaticallyFixTypos: Bool + /// When true, a second press of the Accept Word key within `DoubleTapAcceptanceState.window` + /// accepts the rest of the suggestion. Travels in the snapshot so the acceptance path reads the + /// live value without subscribing to the settings model. + let doubleTapAcceptsEntireSuggestion: Bool /// Single chokepoint that picks between the preset's range and the user's custom range. /// Every downstream consumer (token-budget math, prompt-instruction text, UI labels in the diff --git a/Cotabby/Support/Settings/SuggestionSettingsStore.swift b/Cotabby/Support/Settings/SuggestionSettingsStore.swift index 2f724e93..c7172a4a 100644 --- a/Cotabby/Support/Settings/SuggestionSettingsStore.swift +++ b/Cotabby/Support/Settings/SuggestionSettingsStore.swift @@ -116,6 +116,7 @@ struct SuggestionSettingsStore { private static let pauseStateDefaultsKey = "cotabbySuggestionPauseState" private static let disabledAppRulesDefaultsKey = "cotabbyDisabledAppRules" private static let perAppShortcutOverridesDefaultsKey = "cotabbyPerAppShortcutOverrides" + private static let doubleTapAcceptsEntireSuggestionDefaultsKey = "cotabbyDoubleTapAcceptsEntireSuggestion" private static let suggestInIntegratedTerminalsDefaultsKey = "cotabbySuggestInIntegratedTerminals" private static let showCaretIndicatorDefaultsKey = "cotabbyShowCaretIndicator" private static let selectedIndicatorModeDefaultsKey = "cotabbySelectedIndicatorMode" @@ -209,6 +210,7 @@ struct SuggestionSettingsStore { pauseStateDefaultsKey, disabledAppRulesDefaultsKey, perAppShortcutOverridesDefaultsKey, + doubleTapAcceptsEntireSuggestionDefaultsKey, suggestInIntegratedTerminalsDefaultsKey, showCaretIndicatorDefaultsKey, selectedIndicatorModeDefaultsKey, @@ -294,6 +296,10 @@ struct SuggestionSettingsStore { let resolvedPauseState = persistedPauseState?.activeState() let resolvedDisabledAppRules = loadDisabledAppRules() let resolvedPerAppShortcutOverrides = loadPerAppShortcutOverrides() + // Off by default: a fast second Tab has always accepted one more word, and turning it into a + // whole-suggestion accept is an opt-in change to that muscle memory. + let resolvedDoubleTapAcceptsEntireSuggestion = + userDefaults.object(forKey: Self.doubleTapAcceptsEntireSuggestionDefaultsKey) as? Bool ?? false let resolvedShowIndicator: Bool = if let modeString = userDefaults.string( forKey: Self.selectedIndicatorModeDefaultsKey ) { @@ -650,7 +656,8 @@ struct SuggestionSettingsStore { modifiers: resolvedGlobalToggleKeyModifiers, label: resolvedGlobalToggleKeyLabel ), - perAppOverrides: resolvedPerAppShortcutOverrides + perAppOverrides: resolvedPerAppShortcutOverrides, + doubleTapAcceptsEntireSuggestion: resolvedDoubleTapAcceptsEntireSuggestion ) ) @@ -722,6 +729,7 @@ struct SuggestionSettingsStore { label: data.globalToggleKeyLabel ) savePerAppShortcutOverrides(data.perAppShortcutOverrides) + saveDoubleTapAcceptsEntireSuggestion(data.doubleTapAcceptsEntireSuggestion) saveAcceptanceGranularity(data.acceptanceGranularity) savePowerBasedModelSwitchingEnabled(data.isPowerBasedModelSwitchingEnabled) saveBatteryEngine(data.batteryEngine) @@ -786,6 +794,10 @@ struct SuggestionSettingsStore { } /// Removing the key for an empty list keeps reset state identical to a fresh install. + func saveDoubleTapAcceptsEntireSuggestion(_ enabled: Bool) { + userDefaults.set(enabled, forKey: Self.doubleTapAcceptsEntireSuggestionDefaultsKey) + } + func savePerAppShortcutOverrides(_ overrides: [PerAppShortcutOverride]) { guard !overrides.isEmpty else { userDefaults.removeObject(forKey: Self.perAppShortcutOverridesDefaultsKey) diff --git a/Cotabby/Support/Suggestion/Acceptance/DoubleTapAcceptanceState.swift b/Cotabby/Support/Suggestion/Acceptance/DoubleTapAcceptanceState.swift new file mode 100644 index 00000000..95cda9b7 --- /dev/null +++ b/Cotabby/Support/Suggestion/Acceptance/DoubleTapAcceptanceState.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Pure state machine that recognizes a double press of the Accept Word key. +/// +/// When the user enables "double-tap to accept the entire suggestion", the first press still accepts +/// one word immediately (no added latency on the common single-press path), and a second press of the +/// same key inside `window` accepts everything that remains. The net effect of the pair is that the +/// whole suggestion lands in the field. +/// +/// The coordinator owns one instance for its lifetime and supplies the clock and the session token. +/// Keeping the timing rule here, instead of inline in the coordinator, makes the invariants testable: +/// a pair only counts on the *same* suggestion, inside the window, and each pair is consumed once so a +/// triple press cannot promote twice. +struct DoubleTapAcceptanceState: Equatable { + /// Identifies one suggestion across its word-by-word advancement. Accepting a word keeps the + /// session's generation and full text and only moves its consumed count, so these two values + /// stay stable between the two presses, while a regenerated suggestion changes the generation. + struct SessionToken: Equatable { + let generation: UInt64 + let fullText: String + } + + /// Longest gap between the two presses that still counts as a double tap. Shorter than the + /// system double-click interval (0.5 s by default) so deliberate one-word-at-a-time Tabbing at a + /// normal pace keeps accepting single words. + static let window: TimeInterval = 0.3 + + private struct PendingPress: Equatable { + let session: SessionToken + let uptime: TimeInterval + } + + private var pendingPress: PendingPress? + + var hasPendingPress: Bool { + pendingPress != nil + } + + /// Remembers a first press that accepted a word and left the suggestion with text remaining. + mutating func recordWordAccept(of session: SessionToken, at uptime: TimeInterval) { + pendingPress = PendingPress(session: session, uptime: uptime) + } + + /// Returns true when this press completes a double tap. Always clears the pending press, so a + /// missed pair does not linger and a completed pair cannot be reused by a third press. + mutating func consumeDoubleTap(of session: SessionToken, at uptime: TimeInterval) -> Bool { + defer { pendingPress = nil } + guard let pendingPress, pendingPress.session == session else { + return false + } + let elapsed = uptime - pendingPress.uptime + return elapsed >= 0 && elapsed <= Self.window + } + + /// Cancels a pending first press, e.g. when the user types or moves the caret between presses. + mutating func reset() { + pendingPress = nil + } +} + +extension DoubleTapAcceptanceState.SessionToken { + init(session: ActiveSuggestionSession) { + self.init(generation: session.baseContext.generation, fullText: session.fullText) + } +} diff --git a/Cotabby/UI/Onboarding/Welcome/WelcomeKeybindStepView.swift b/Cotabby/UI/Onboarding/Welcome/WelcomeKeybindStepView.swift index c2065051..e6149c81 100644 --- a/Cotabby/UI/Onboarding/Welcome/WelcomeKeybindStepView.swift +++ b/Cotabby/UI/Onboarding/Welcome/WelcomeKeybindStepView.swift @@ -58,7 +58,7 @@ struct WelcomeKeybindStepView: View { keybindRow( title: "Accept entire suggestion", - keyLabel: suggestionSettings.fullAcceptanceKeyLabel, + keyLabel: suggestionSettings.fullAcceptanceDisplayLabel, action: .acceptEntireSuggestion, onKeyRecorded: { keyCode, modifiers, label in suggestionSettings.setFullAcceptanceKey( @@ -67,18 +67,23 @@ struct WelcomeKeybindStepView: View { label: label ) }, - onReset: ( - suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.defaultFullAcceptanceKeyCode - || !suggestionSettings.fullAcceptanceKeyModifiers.isEmpty - ) ? { + onReset: !suggestionSettings.isFullAcceptanceShortcutDefault ? { suggestionSettings.setFullAcceptanceKey( keyCode: SuggestionSettingsModel.defaultFullAcceptanceKeyCode, modifiers: [], label: SuggestionSettingsModel.defaultFullAcceptanceKeyLabel ) } : nil, - onClear: suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode - ? { suggestionSettings.clearFullAcceptanceKey() } : nil + onClear: suggestionSettings.hasFullAcceptanceShortcut + ? { suggestionSettings.clearFullAcceptanceKey() } : nil, + // Pressing Accept Word twice here binds the double tap, the same as in Settings. + doubleTapKey: suggestionSettings.acceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode + ? DoubleTapRecordingKey( + keyCode: suggestionSettings.acceptanceKeyCode, + modifiers: suggestionSettings.acceptanceKeyModifiers, + label: suggestionSettings.acceptanceKeyLabel + ) : nil, + onDoubleTapRecorded: { suggestionSettings.setDoubleTapFullAcceptance() } ) .onboardingReveal(3) @@ -112,7 +117,9 @@ struct WelcomeKeybindStepView: View { action: ShortcutAction, onKeyRecorded: @escaping (CGKeyCode, ShortcutModifierMask, String) -> Void, onReset: (() -> Void)? = nil, - onClear: (() -> Void)? = nil + onClear: (() -> Void)? = nil, + doubleTapKey: DoubleTapRecordingKey? = nil, + onDoubleTapRecorded: (() -> Void)? = nil ) -> some View { let isRecording = recordingAction == action HStack(spacing: 10) { @@ -137,6 +144,13 @@ struct WelcomeKeybindStepView: View { modifiers: modifiers, excluding: action ) + }, + doubleTapKey: doubleTapKey, + onDoubleTapRecorded: onDoubleTapRecorded.map { record in + { + record() + recordingAction = nil + } } ) } else { diff --git a/Cotabby/UI/Onboarding/Welcome/WelcomeView.swift b/Cotabby/UI/Onboarding/Welcome/WelcomeView.swift index d00e6727..87638a48 100644 --- a/Cotabby/UI/Onboarding/Welcome/WelcomeView.swift +++ b/Cotabby/UI/Onboarding/Welcome/WelcomeView.swift @@ -445,8 +445,10 @@ extension WelcomeView { private var doneStepSubtitle: String { let wordKey = suggestionSettings.acceptanceKeyLabel - let fullKey = suggestionSettings.fullAcceptanceKeyLabel - let hasFullAccept = suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode + let fullKey = suggestionSettings.isDoubleTapFullAcceptanceActive + ? "\(wordKey) twice" + : suggestionSettings.fullAcceptanceKeyLabel + let hasFullAccept = suggestionSettings.hasFullAcceptanceShortcut if hasFullAccept { return "Start typing anywhere.\nPress \(wordKey) to accept a word, \(fullKey) for the full suggestion." diff --git a/Cotabby/UI/Settings/Components/Controls/KeyRecorderView.swift b/Cotabby/UI/Settings/Components/Controls/KeyRecorderView.swift index 0387653c..d9b7bda6 100644 --- a/Cotabby/UI/Settings/Components/Controls/KeyRecorderView.swift +++ b/Cotabby/UI/Settings/Components/Controls/KeyRecorderView.swift @@ -15,10 +15,16 @@ struct KeyRecorderView: View { /// When it reports a conflict the recorder refuses to commit and keeps listening, so a shortcut /// can never be assigned to two actions at once. var conflictChecker: ((CGKeyCode, ShortcutModifierMask) -> String?)? + /// A key that may be recorded as a double press instead of a single key: the Accept Word key, + /// when recording Accept Entire Suggestion. A single press of it is still a conflict. + var doubleTapKey: DoubleTapRecordingKey? + var onDoubleTapRecorded: (() -> Void)? @State private var monitor: Any? @State private var liveModifiers: ShortcutModifierMask = [] @State private var conflictMessage: String? + /// Event timestamp of a first press of `doubleTapKey` still waiting for its second press. + @State private var pendingFirstTapTimestamp: TimeInterval? var body: some View { Text(promptText) @@ -31,6 +37,9 @@ struct KeyRecorderView: View { if let conflictMessage { return conflictMessage } + if pendingFirstTapTimestamp != nil, let doubleTapKey { + return "Press \(doubleTapKey.label) again…" + } let glyphs = KeyCodeLabels.modifierGlyphs(liveModifiers) return glyphs.isEmpty ? "Press a key…" : "\(glyphs) + key…" } @@ -63,6 +72,12 @@ struct KeyRecorderView: View { return nil } + if let doubleTapKey, onDoubleTapRecorded != nil, + keyCode == doubleTapKey.keyCode, modifiers == doubleTapKey.modifiers { + return handleDoubleTapCandidate(event: event, key: doubleTapKey) + } + pendingFirstTapTimestamp = nil + // Any other key is fair game. The pipeline is key-agnostic: `InputMonitor.classify` // matches the bound shortcut before its behavioral branches, and acceptance only // consumes the key while a suggestion is visible (otherwise it passes through and does @@ -89,6 +104,34 @@ struct KeyRecorderView: View { return nil } + /// The first press waits up to `DoubleTapAcceptanceState.window` for a second one, the same + /// window acceptance uses, so a pair that records here is a pair that works while typing. If no + /// second press comes, the press is reported as the usual conflict, with a pointer to the + /// double tap so the gesture is discoverable from the error. + private func handleDoubleTapCandidate(event: NSEvent, key: DoubleTapRecordingKey) -> NSEvent? { + // Holding the key auto-repeats; that is one long press, not a double tap. + guard !event.isARepeat else { return nil } + + let timestamp = event.timestamp + if let first = pendingFirstTapTimestamp, timestamp - first <= DoubleTapAcceptanceState.window { + pendingFirstTapTimestamp = nil + removeMonitor() + onDoubleTapRecorded?() + return nil + } + + pendingFirstTapTimestamp = timestamp + conflictMessage = nil + DispatchQueue.main.asyncAfter(deadline: .now() + DoubleTapAcceptanceState.window) { + // Only the press that scheduled this timeout may turn into an error. + guard pendingFirstTapTimestamp == timestamp else { return } + pendingFirstTapTimestamp = nil + let owner = conflictChecker?(key.keyCode, key.modifiers) ?? "another shortcut" + conflictMessage = "Already used by \(owner). Press it twice quickly for a double tap." + } + return nil + } + private func bestCharacterFallback(for event: NSEvent) -> String? { if let ignoring = event.charactersIgnoringModifiers, !ignoring.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { @@ -117,3 +160,10 @@ extension ShortcutModifierMask { self = mask } } + +/// The key a recorder may accept as a double press, with the label shown while it waits. +struct DoubleTapRecordingKey: Equatable { + let keyCode: CGKeyCode + let modifiers: ShortcutModifierMask + let label: String +} diff --git a/Cotabby/UI/Settings/Components/KeybindRow.swift b/Cotabby/UI/Settings/Components/KeybindRow.swift index 8c314809..419296af 100644 --- a/Cotabby/UI/Settings/Components/KeybindRow.swift +++ b/Cotabby/UI/Settings/Components/KeybindRow.swift @@ -14,6 +14,11 @@ struct KeybindRow: View { let clearLabel: String let clearHelp: String let conflictChecker: (CGKeyCode, ShortcutModifierMask) -> String? + /// Overrides the "is anything bound" check behind Clear, for a binding that is not a single + /// key code (a double tap). `nil` derives it from `keyCode`. + var isBound: Bool? + var doubleTapKey: DoubleTapRecordingKey? + var onDoubleTapRecorded: (() -> Void)? var body: some View { HStack(spacing: 8) { @@ -26,7 +31,14 @@ struct KeybindRow: View { isRecording = false }, onCancelled: { isRecording = false }, - conflictChecker: conflictChecker + conflictChecker: conflictChecker, + doubleTapKey: doubleTapKey, + onDoubleTapRecorded: onDoubleTapRecorded.map { record in + { + record() + isRecording = false + } + } ) } else { Button("Change") { @@ -41,7 +53,7 @@ struct KeybindRow: View { } } - if keyCode != SuggestionSettingsModel.disabledKeyCode { + if isBound ?? (keyCode != SuggestionSettingsModel.disabledKeyCode) { Button(clearLabel) { onClear() isRecording = false diff --git a/Cotabby/UI/Settings/Panes/AppsPaneView.swift b/Cotabby/UI/Settings/Panes/AppsPaneView.swift index bc20470d..18152e94 100644 --- a/Cotabby/UI/Settings/Panes/AppsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppsPaneView.swift @@ -171,7 +171,8 @@ struct AppsPaneView: View { override: override, action: .acceptEntireSuggestion, title: "Accept Entire Suggestion", - inheritsHelp: "Uses the global shortcut (\(suggestionSettings.fullAcceptanceKeyLabel)). " + inheritsHelp: "Uses the global shortcut (" + + "\(suggestionSettings.inheritedFullAcceptanceDisplayLabel(forBundleIdentifier: override.bundleIdentifier))). " + "Click Change to set a custom key for \(override.displayName)." ) } @@ -196,7 +197,7 @@ struct AppsPaneView: View { .frame(width: 180, alignment: .leading) if inherits { - Text("Uses global (\(binding.label))") + Text("Uses global (\(inheritedLabel(override: override, action: action, binding: binding)))") .font(.caption) .foregroundStyle(.secondary) .help(inheritsHelp) @@ -261,6 +262,16 @@ struct AppsPaneView: View { } } + /// The inherited shortcut as this app sees it; for full acceptance that may be a double tap. + private func inheritedLabel( + override: PerAppShortcutOverride, + action: PerAppShortcutAction, + binding: ShortcutResolver.ResolvedBinding + ) -> String { + guard action == .acceptEntireSuggestion else { return binding.label } + return suggestionSettings.inheritedFullAcceptanceDisplayLabel(forBundleIdentifier: override.bundleIdentifier) + } + private func recordingBinding( forBundleIdentifier bundleIdentifier: String, action: PerAppShortcutAction diff --git a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift index cccd8d79..f2162be4 100644 --- a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift @@ -61,9 +61,11 @@ struct ShortcutsPaneView: View { } .settingsItem(.acceptWord) + // One slot, one shortcut: either a one-press key or a double press of Accept Word. + // The recorder takes either, so the gesture is set where users look for it. LabeledContent { KeybindRow( - label: suggestionSettings.fullAcceptanceKeyLabel, + label: suggestionSettings.fullAcceptanceDisplayLabel, keyCode: suggestionSettings.fullAcceptanceKeyCode, isRecording: $isRecordingFullAcceptKeybind, onRecord: { keyCode, modifiers, label in @@ -81,9 +83,7 @@ struct ShortcutsPaneView: View { ) }, resetLabel: "Reset", - shouldShowReset: suggestionSettings.fullAcceptanceKeyCode - != SuggestionSettingsModel.defaultFullAcceptanceKeyCode - || !suggestionSettings.fullAcceptanceKeyModifiers.isEmpty, + shouldShowReset: !suggestionSettings.isFullAcceptanceShortcutDefault, onClear: { suggestionSettings.clearFullAcceptanceKey() }, clearLabel: "Clear", clearHelp: "Unbind this shortcut. No key will accept the whole suggestion at once.", @@ -93,12 +93,15 @@ struct ShortcutsPaneView: View { modifiers: modifiers, excluding: .acceptEntireSuggestion ) - } + }, + isBound: suggestionSettings.hasFullAcceptanceShortcut, + doubleTapKey: acceptWordDoubleTapKey, + onDoubleTapRecorded: { suggestionSettings.setDoubleTapFullAcceptance() } ) } label: { SettingsRowLabel( title: "Accept Entire Suggestion", - description: "Insert the whole remaining suggestion in one keystroke.", + description: fullAcceptanceDescription, systemImage: "text.insert" ) } @@ -142,4 +145,28 @@ struct ShortcutsPaneView: View { } } } + + /// The Accept Word key, offered to the recorder as a double press. Nil while Accept Word is + /// unbound, because there is then no key to press twice. + private var acceptWordDoubleTapKey: DoubleTapRecordingKey? { + guard suggestionSettings.acceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode else { + return nil + } + return DoubleTapRecordingKey( + keyCode: suggestionSettings.acceptanceKeyCode, + modifiers: suggestionSettings.acceptanceKeyModifiers, + label: suggestionSettings.acceptanceKeyLabel + ) + } + + private var fullAcceptanceDescription: String { + let wordKey = suggestionSettings.acceptanceKeyLabel + if suggestionSettings.isDoubleTapFullAcceptanceActive { + return "Press \(wordKey) twice quickly to insert the whole suggestion. A single press still inserts one word." + } + if acceptWordDoubleTapKey != nil { + return "Insert the whole remaining suggestion at once. For a double tap, click Change and press \(wordKey) twice." + } + return "Insert the whole remaining suggestion in one keystroke." + } } diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 29ce5545..ee110a8c 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -380,7 +380,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .endpointModel: return "The model identifier sent to the configured endpoint." case .acceptanceMode: return "Whether the accept key takes a word or a phrase." case .acceptWord: return "The key that inserts the next word." - case .acceptEntireSuggestion: return "The key that inserts the whole suggestion." + case .acceptEntireSuggestion: return "The key, or Tab pressed twice, that inserts the whole suggestion." case .toggleTabby: return "A global hotkey that turns Cotabby on or off." case .disabledApps: return "Apps where Cotabby never autocompletes." case .suggestInIntegratedTerminals: return "Ghost text in VS Code and Cursor terminals." @@ -589,7 +589,8 @@ enum SettingsItem: String, CaseIterable, Identifiable { "hotkey", "accept word", "next word"] case .acceptEntireSuggestion: return ["accept all", "entire", "full", "shortcut", "complete", "all", - "whole", "everything", "keybind", "binding"] + "whole", "everything", "keybind", "binding", "double tap", "double-tap", + "twice", "tab tab", "press twice"] case .toggleTabby: return ["toggle", "global", "on off", "shortcut", "hotkey", "pause", "enable", "disable", "keybind", "binding", "tabby"] diff --git a/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorDoubleTapTests.swift b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorDoubleTapTests.swift new file mode 100644 index 00000000..ed1dcd0c --- /dev/null +++ b/CotabbyTests/App/Coordinators/Suggestion/SuggestionCoordinatorDoubleTapTests.swift @@ -0,0 +1,86 @@ +import CoreGraphics +import Foundation +import XCTest +@testable import Cotabby + +/// Drives real Accept Word key events through the coordinator to pin the double-tap contract: the +/// first press always takes one word, a quick second press on the same suggestion takes the rest, +/// and anything else (setting off, a slow press, an intervening key) keeps word-by-word acceptance. +/// +/// The rig's host never publishes inserted text, so every accept reconciles against the original +/// "Hello " and drops the chunk's leading space; the expected chunks below reflect that. +@MainActor +final class SuggestionCoordinatorDoubleTapTests: XCTestCase { + private let tab = CapturedInputEvent(kind: .acceptance, keyCode: 48, characters: "\t", flags: []) + + func testQuickSecondPressAcceptsTheRestOfTheSuggestion() async { + let rig = await makeReadyRig(doubleTapEnabled: true) + defer { rig.coordinator.stop() } + + XCTAssertTrue(rig.coordinator.handleInputEvent(tab)) + XCTAssertEqual(rig.inserter.insertedChunks, ["world"]) + + XCTAssertTrue(rig.coordinator.handleInputEvent(tab)) + XCTAssertEqual(rig.inserter.insertedChunks, ["world", "again tomorrow"]) + XCTAssertNil(rig.interactionState.activeSession, "The pair must exhaust the suggestion") + } + + func testSettingOffKeepsWordByWordAcceptance() async { + let rig = await makeReadyRig(doubleTapEnabled: false) + defer { rig.coordinator.stop() } + + XCTAssertTrue(rig.coordinator.handleInputEvent(tab)) + XCTAssertTrue(rig.coordinator.handleInputEvent(tab)) + + XCTAssertEqual(rig.inserter.insertedChunks, ["world", "again"]) + XCTAssertEqual(rig.interactionState.activeSession?.remainingText, " tomorrow") + } + + func testSecondPressAfterTheWindowAcceptsOnlyTheNextWord() async { + let rig = await makeReadyRig(doubleTapEnabled: true) + defer { rig.coordinator.stop() } + + XCTAssertTrue(rig.coordinator.handleInputEvent(tab)) + // Backdate the first press past the window instead of sleeping in the test. + guard let session = rig.interactionState.activeSession else { + return XCTFail("Expected the suggestion to remain active after one word") + } + rig.coordinator.doubleTapAcceptanceState.recordWordAccept( + of: .init(session: session), + at: ProcessInfo.processInfo.systemUptime - DoubleTapAcceptanceState.window - 1 + ) + + XCTAssertTrue(rig.coordinator.handleInputEvent(tab)) + XCTAssertEqual(rig.inserter.insertedChunks, ["world", "again"]) + } + + func testInterveningKeyCancelsThePendingDoubleTap() async { + let rig = await makeReadyRig(doubleTapEnabled: true) + defer { rig.coordinator.stop() } + + XCTAssertTrue(rig.coordinator.handleInputEvent(tab)) + // A modifier-only or unmapped key leaves the suggestion alone but still breaks the pair. + _ = rig.coordinator.handleInputEvent( + CapturedInputEvent(kind: .other, keyCode: 56, characters: "", flags: .maskShift) + ) + + XCTAssertFalse(rig.coordinator.doubleTapAcceptanceState.hasPendingPress) + } + + private func makeReadyRig(doubleTapEnabled: Bool) async -> CoordinatorRig { + let rig = makeCoordinatorRig( + snapshot: CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello "), + settingsSnapshot: CotabbyTestFixtures.settingsSnapshot( + debounceMilliseconds: 1, + doubleTapAcceptsEntireSuggestion: doubleTapEnabled + ) + ) + rig.engine.resultProvider = { request in + SuggestionResult(generation: request.generation, rawText: "world again tomorrow", + text: "world again tomorrow", latency: 0.01) + } + rig.coordinator.schedulePrediction() + await waitUntil { rig.interactionState.activeSession != nil } + return rig + } +} diff --git a/CotabbyTests/Models/Settings/SuggestionSettingsModelDoubleTapTests.swift b/CotabbyTests/Models/Settings/SuggestionSettingsModelDoubleTapTests.swift new file mode 100644 index 00000000..58b207c4 --- /dev/null +++ b/CotabbyTests/Models/Settings/SuggestionSettingsModelDoubleTapTests.swift @@ -0,0 +1,142 @@ +import CoreGraphics +import XCTest +@testable import Cotabby + +/// Locks the "one slot, one shortcut" rule for Accept Entire Suggestion: it is either a one-press +/// key or a double press of Accept Word, never both, and the display helpers views read agree with +/// whichever one is stored. +@MainActor +final class SuggestionSettingsModelDoubleTapTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUp() { + super.setUp() + suiteName = "cotabby.test.settingsModelDoubleTap.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + private func makeModel() -> SuggestionSettingsModel { + SuggestionSettingsModel(configuration: .standard, userDefaults: defaults) + } + + func test_defaultSlotHoldsTheFactoryOnePressKey() { + let model = makeModel() + + XCTAssertFalse(model.isDoubleTapFullAcceptanceActive) + XCTAssertTrue(model.isFullAcceptanceShortcutDefault) + XCTAssertEqual(model.fullAcceptanceDisplayLabel, SuggestionSettingsModel.defaultFullAcceptanceKeyLabel) + XCTAssertFalse(model.snapshot.doubleTapAcceptsEntireSuggestion) + } + + func test_recordingDoubleTapReplacesTheOnePressKeyAndPersists() { + let model = makeModel() + + model.setDoubleTapFullAcceptance() + + XCTAssertTrue(model.isDoubleTapFullAcceptanceActive) + XCTAssertEqual(model.fullAcceptanceKeyCode, SuggestionSettingsModel.disabledKeyCode) + XCTAssertEqual(model.fullAcceptanceDisplayLabel, "Tab Tab") + XCTAssertTrue(model.hasFullAcceptanceShortcut) + XCTAssertFalse(model.isFullAcceptanceShortcutDefault) + XCTAssertTrue(model.snapshot.doubleTapAcceptsEntireSuggestion) + + let reloaded = makeModel() + XCTAssertTrue(reloaded.isDoubleTapFullAcceptanceActive) + XCTAssertEqual(reloaded.fullAcceptanceKeyCode, SuggestionSettingsModel.disabledKeyCode) + } + + func test_recordingAOnePressKeyReplacesTheDoubleTap() { + let model = makeModel() + model.setDoubleTapFullAcceptance() + + model.setFullAcceptanceKey(keyCode: 48, modifiers: [.option], label: "⌥ Tab") + + XCTAssertFalse(model.isDoubleTapFullAcceptanceActive) + XCTAssertEqual(model.fullAcceptanceDisplayLabel, "⌥ Tab") + XCTAssertFalse(makeModel().doubleTapAcceptsEntireSuggestion) + } + + func test_resetReturnsToTheFactoryKey() { + let model = makeModel() + model.setDoubleTapFullAcceptance() + + model.setFullAcceptanceKey( + keyCode: SuggestionSettingsModel.defaultFullAcceptanceKeyCode, + modifiers: [], + label: SuggestionSettingsModel.defaultFullAcceptanceKeyLabel + ) + + XCTAssertTrue(model.isFullAcceptanceShortcutDefault) + XCTAssertFalse(model.isDoubleTapFullAcceptanceActive) + } + + func test_clearRemovesTheDoubleTapToo() { + let model = makeModel() + model.setDoubleTapFullAcceptance() + + model.clearFullAcceptanceKey() + + XCTAssertFalse(model.hasFullAcceptanceShortcut) + XCTAssertFalse(model.doubleTapAcceptsEntireSuggestion) + XCTAssertEqual(model.fullAcceptanceDisplayLabel, SuggestionSettingsModel.disabledKeyLabel) + } + + func test_doubleTapCannotBeBoundWithoutAnAcceptWordKey() { + let model = makeModel() + model.clearAcceptanceKey() + + model.setDoubleTapFullAcceptance() + + XCTAssertFalse(model.doubleTapAcceptsEntireSuggestion) + XCTAssertTrue(model.isFullAcceptanceShortcutDefault, "The one-press key must survive the refused bind") + } + + func test_clearingAcceptWordAlsoRemovesTheDoubleTap() { + let model = makeModel() + model.setDoubleTapFullAcceptance() + + model.clearAcceptanceKey() + + XCTAssertFalse(model.doubleTapAcceptsEntireSuggestion) + XCTAssertFalse(model.hasFullAcceptanceShortcut) + } + + func test_doubleTapLabelFollowsTheAcceptWordKey() { + let model = makeModel() + model.setDoubleTapFullAcceptance() + + model.setAcceptanceKey(keyCode: 36, modifiers: [], label: "Return") + + XCTAssertEqual(model.fullAcceptanceDisplayLabel, "Return Return") + } + + func test_inheritedLabelUsesTheAppsOwnAcceptWordKey() { + let model = makeModel() + model.setDoubleTapFullAcceptance() + model.setPerAppAcceptKey(bundleIdentifier: "com.apple.Terminal", displayName: "Terminal", + keyCode: 36, modifiers: [], label: "Return") + + XCTAssertEqual(model.inheritedFullAcceptanceDisplayLabel(forBundleIdentifier: "com.apple.mail"), "Tab Tab") + XCTAssertEqual(model.inheritedFullAcceptanceDisplayLabel(forBundleIdentifier: "com.apple.Terminal"), "Return Return") + } + + func test_inheritedLabelHasNoDoubleTapWhereAcceptWordIsDisabled() { + let model = makeModel() + model.setDoubleTapFullAcceptance() + model.setPerAppAcceptKey(bundleIdentifier: "com.apple.Terminal", displayName: "Terminal", + keyCode: SuggestionSettingsModel.disabledKeyCode, modifiers: [], label: "None") + + XCTAssertEqual(model.inheritedFullAcceptanceDisplayLabel(forBundleIdentifier: "com.apple.Terminal"), + SuggestionSettingsModel.disabledKeyLabel) + } + +} diff --git a/CotabbyTests/Support/Suggestion/Acceptance/DoubleTapAcceptanceStateTests.swift b/CotabbyTests/Support/Suggestion/Acceptance/DoubleTapAcceptanceStateTests.swift new file mode 100644 index 00000000..ad8ff50d --- /dev/null +++ b/CotabbyTests/Support/Suggestion/Acceptance/DoubleTapAcceptanceStateTests.swift @@ -0,0 +1,66 @@ +@testable import Cotabby +import XCTest + +final class DoubleTapAcceptanceStateTests: XCTestCase { + private let session = DoubleTapAcceptanceState.SessionToken(generation: 7, fullText: " world and more") + + func test_secondPressWithinWindowOnSameSession_isDoubleTap() { + var state = DoubleTapAcceptanceState() + state.recordWordAccept(of: session, at: 10.0) + + XCTAssertTrue(state.consumeDoubleTap(of: session, at: 10.2)) + } + + func test_secondPressAfterWindow_isNotDoubleTap() { + var state = DoubleTapAcceptanceState() + state.recordWordAccept(of: session, at: 10.0) + + XCTAssertFalse(state.consumeDoubleTap(of: session, at: 10.0 + DoubleTapAcceptanceState.window + 0.01)) + } + + func test_pressOnDifferentSession_isNotDoubleTap() { + var state = DoubleTapAcceptanceState() + state.recordWordAccept(of: session, at: 10.0) + let regenerated = DoubleTapAcceptanceState.SessionToken(generation: 8, fullText: " world and more") + + XCTAssertFalse(state.consumeDoubleTap(of: regenerated, at: 10.1)) + } + + func test_firstPressWithoutRecordedAccept_isNotDoubleTap() { + var state = DoubleTapAcceptanceState() + + XCTAssertFalse(state.consumeDoubleTap(of: session, at: 10.0)) + } + + func test_consumingEndsThePair_soATripleTapIsNotASecondDoubleTap() { + var state = DoubleTapAcceptanceState() + state.recordWordAccept(of: session, at: 10.0) + + XCTAssertTrue(state.consumeDoubleTap(of: session, at: 10.1)) + XCTAssertFalse(state.consumeDoubleTap(of: session, at: 10.2)) + } + + func test_missedDoubleTapAlsoClearsThePendingPress() { + var state = DoubleTapAcceptanceState() + state.recordWordAccept(of: session, at: 10.0) + + XCTAssertFalse(state.consumeDoubleTap(of: session, at: 11.0)) + XCTAssertFalse(state.hasPendingPress) + } + + func test_resetCancelsThePendingPress() { + var state = DoubleTapAcceptanceState() + state.recordWordAccept(of: session, at: 10.0) + + state.reset() + + XCTAssertFalse(state.consumeDoubleTap(of: session, at: 10.1)) + } + + func test_clockGoingBackwards_isNotDoubleTap() { + var state = DoubleTapAcceptanceState() + state.recordWordAccept(of: session, at: 10.0) + + XCTAssertFalse(state.consumeDoubleTap(of: session, at: 9.9)) + } +} diff --git a/CotabbyTests/TestSupport/CotabbyTestFixtures.swift b/CotabbyTests/TestSupport/CotabbyTestFixtures.swift index 25dd9819..b8f297e2 100644 --- a/CotabbyTests/TestSupport/CotabbyTestFixtures.swift +++ b/CotabbyTests/TestSupport/CotabbyTestFixtures.swift @@ -270,7 +270,8 @@ enum CotabbyTestFixtures { suppressCompletionsOnTypo: Bool = false, offerTypoCorrections: Bool = false, enabledSpellingDictionaryCodes: [String] = SpellingDictionaryCatalog.defaultEnabledCodes, - automaticallyFixTypos: Bool = false + automaticallyFixTypos: Bool = false, + doubleTapAcceptsEntireSuggestion: Bool = false ) -> SuggestionSettingsSnapshot { SuggestionSettingsSnapshot( isGloballyEnabled: isGloballyEnabled, @@ -303,7 +304,8 @@ enum CotabbyTestFixtures { suppressCompletionsOnTypo: suppressCompletionsOnTypo, offerTypoCorrections: offerTypoCorrections, enabledSpellingDictionaryCodes: enabledSpellingDictionaryCodes, - automaticallyFixTypos: automaticallyFixTypos + automaticallyFixTypos: automaticallyFixTypos, + doubleTapAcceptsEntireSuggestion: doubleTapAcceptsEntireSuggestion ) } }